Compare commits

..

14 Commits

Author SHA1 Message Date
bircni
aa7a29a157 fix: guard status-check functions against a nil job context (#1092)
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>
2026-07-16 21:33:26 +00:00
Renovate Bot
ad967330a8 fix(deps): update module golang.org/x/text to v0.40.0 (#1091)
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.

---

 - [ ] <!-- 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/1091
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-16 09:22:33 +00:00
bircni
60177008a5 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>
2026-07-15 19:45:50 +00:00
bircni
58c5eb8d21 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 <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1085
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-07-15 16:58:07 +00:00
bircni
d6882b3df5 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 <xiaolunwen@gmail.com>
2026-07-15 06:34:32 +00:00
bircni
7e7e3ef1a6 fix: stop service containers from clobbering the job container's credentials (#1083)
Fixes #835
Fixes #643

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1083
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-15 06:15:50 +00:00
Nicolas
16357a34b2 fix: accept natively typed boolean workflow inputs (#1087)
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 <bircni@icloud.com>
2026-07-14 20:16:19 +00:00
Nicolas
d53538ac38 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 <zettat123@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1070
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-07-14 14:28:57 +00:00
Renovate Bot
554b3b7671 fix(deps): update module golang.org/x/term to v0.45.0 (#1081)
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.

---

 - [ ] <!-- 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/1081
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-14 11:19:52 +00:00
Nicolas
65756d60b3 fix: Minor fixes (#1075)
A batch of small, self-contained fixes and docs/example additions.

Fixes #625 - align the example config's `force_pull` with the actual default (`false`)
Fixes #804 - return an error instead of discarding `os.UserHomeDir()` when defaulting `cache.dir`/`host.workdir_parent`
Fixes #571 - send a `gitea-runner/<version>` User-Agent on API requests
Fixes #650 - detect an `Unauthenticated` fetch response and exit the daemon with an error instead of retrying forever
Fixes #766 - add `exec --eventpath` to supply a JSON event payload file
Fixes #256 - add a `bug-report` subcommand that prints version/Go/OS-arch/CPU info
Fixes #617 - add `runner.set_act_env` (default `true`) to optionally omit the `ACT=true` env var
Fixes #635 - record a failure result (and guard a nil reusable-workflow caller) when the job `if`-expression fails to evaluate
Fixes #1005 - remove README docs for config env-var overrides that were already removed from the code
Fixes #448 - clarify in `exec --job` help that `--workflows` may be needed to disambiguate
Fixes #209 - note that `host`-labelled runners still need Docker for `docker://` actions and service containers
Fixes #757 - add a systemd service example with automatic restart
Fixes #474 - add a Kubernetes StatefulSet example that persists the `.runner` registration across reschedules
Fixes #776 - build the basic (non-dind) docker image for `linux/riscv64`
Fixes #628 - build the basic (non-dind) docker image for `linux/s390x`Reviewed-on: https://gitea.com/gitea/runner/pulls/1075
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-13 18:41:19 +00:00
h7x4
be9b4502d6 enhance: add --token-file flag to register command (#1076)
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é <felix@alternativebit.fr>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1076
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: h7x4 <h7x4@nani.wtf>
2026-07-11 17:02:11 +00:00
silverwind
1d74ae636a 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 <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-09 15:08:59 +00:00
Renovate Bot
b12d02c25f fix(deps): update module golang.org/x/sys to v0.47.0 (#1073)
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.

---

 - [ ] <!-- 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/1073
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-09 09:14:35 +00:00
bircni
f2e0cf9131 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 <bircni@icloud.com>
2026-07-04 00:06:26 +00:00
43 changed files with 1067 additions and 216 deletions

View File

@@ -43,12 +43,18 @@ jobs:
strategy: strategy:
matrix: matrix:
variant: 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 - target: basic
tag_suffix: "" tag_suffix: ""
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
- target: dind - target: dind
tag_suffix: "-dind" tag_suffix: "-dind"
platforms: linux/amd64,linux/arm64
- target: dind-rootless - target: dind-rootless
tag_suffix: "-dind-rootless" tag_suffix: "-dind-rootless"
platforms: linux/amd64,linux/arm64
steps: steps:
- name: Checkout - name: Checkout
@@ -82,9 +88,7 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
target: ${{ matrix.variant.target }} target: ${{ matrix.variant.target }}
platforms: | platforms: ${{ matrix.variant.platforms }}
linux/amd64
linux/arm64
push: true push: true
tags: | tags: |
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }} ${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}

View File

@@ -42,12 +42,18 @@ jobs:
strategy: strategy:
matrix: matrix:
variant: 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 - target: basic
tag_suffix: "" tag_suffix: ""
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
- target: dind - target: dind
tag_suffix: "-dind" tag_suffix: "-dind"
platforms: linux/amd64,linux/arm64
- target: dind-rootless - target: dind-rootless
tag_suffix: "-dind-rootless" tag_suffix: "-dind-rootless"
platforms: linux/amd64,linux/arm64
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
env: env:
@@ -91,9 +97,7 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
target: ${{ matrix.variant.target }} target: ${{ matrix.variant.target }}
platforms: | platforms: ${{ matrix.variant.platforms }}
linux/amd64
linux/arm64
push: true push: true
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
build-args: | build-args: |

View File

@@ -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)). 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 ### 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. 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). 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. > **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 ### Configuration
@@ -143,23 +147,68 @@ Every option is described in [config.example.yaml](internal/pkg/config/config.ex
#### Without a config file #### 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 | 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
<name>[:<schema>[:<args>]]
```
| Part | Meaning |
| --- | --- | | --- | --- |
| `GITEA_DEBUG` | If true, sets log level to `debug` | | `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
| `GITEA_TRACE` | If true, sets log level to `trace` | | `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
| `GITEA_RUNNER_CAPACITY` | Concurrent jobs (integer) | | `args` | Only used by the `docker` schema: the image to run the job in. |
| `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. Two schemas are supported:
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
```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 #### 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. 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`) #### 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. Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.

View File

@@ -72,7 +72,9 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
_ = logDockerResponse(logger, reader, err != nil) _ = 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 return nil
} }

View File

@@ -13,6 +13,9 @@ import (
"strings" "strings"
"gitea.com/gitea/runner/act/common" "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 { 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 { if err != nil && err != io.EOF {
return err 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. // 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) s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for s.Scan() { for s.Scan() {
line := s.Text() line := s.Text()
// GitHub's runner ignores blank lines
if strings.TrimSpace(line) == "" {
continue
}
singleLineEnv := strings.Index(line, "=") singleLineEnv := strings.Index(line, "=")
multiLineEnv := strings.Index(line, "<<") multiLineEnv := strings.Index(line, "<<")
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) { if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {

View File

@@ -13,6 +13,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/unicode"
) )
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) { func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
@@ -64,6 +66,63 @@ func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
assert.Contains(t, err.Error(), "reading env file") 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<<EOF\nline1\n\nline2\nEOF\n"), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "line1\n\nline2", env["FOO"])
}
func TestParseEnvFileUTF8BOM(t *testing.T) {
e, envPath := newTestHostEnv(t)
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("FOO=bar\n")...)
require.NoError(t, os.WriteFile(envPath, content, 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "bar", env["FOO"])
}
// Windows host mode: PowerShell 5.1 redirection writes UTF-16, which used to be
// unrecognisable as KEY=VALUE, so the writes were silently ignored.
func TestParseEnvFileUTF16(t *testing.T) {
tests := []struct {
name string
encoder *encoding.Encoder
}{
{"little endian", unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewEncoder()},
{"big endian", unicode.UTF16(unicode.BigEndian, unicode.UseBOM).NewEncoder()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e, envPath := newTestHostEnv(t)
content, err := tt.encoder.Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
require.NoError(t, err)
require.NoError(t, os.WriteFile(envPath, content, 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "bar", env["FOO"])
assert.Equal(t, "line1", env["MULTI"])
})
}
}
func TestParseEnvFileMissingDelimiter(t *testing.T) { func TestParseEnvFileMissingDelimiter(t *testing.T) {
e, envPath := newTestHostEnv(t) e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600)) require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))

View File

@@ -274,8 +274,17 @@ func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pr
return true, nil 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 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 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 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 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
} }

View File

@@ -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)
})
}
}

View File

@@ -229,7 +229,8 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt
logger.Debugf("evaluating expression '%s'", in) logger.Debugf("evaluating expression '%s'", in)
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck) 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) logger.Debugf("expression '%s' evaluated to '%s'", in, printable)
return evaluated, err return evaluated, err
@@ -497,11 +498,7 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod
if value == nil { if value == nil {
value = v.Default value = v.Default
} }
if v.Type == "boolean" { inputs[k] = coerceInputValue(value, v.Type)
inputs[k] = value == "true"
} else {
inputs[k] = value
}
} }
} }
} }
@@ -514,17 +511,26 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod
if value == nil { if value == nil {
value = v.Default value = v.Default
} }
if v.Type == "boolean" { inputs[k] = coerceInputValue(value, v.Type)
inputs[k] = value == "true"
} else {
inputs[k] = value
}
} }
} }
} }
return inputs 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) { func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
if rc.caller != nil { if rc.caller != nil {
config := rc.Run.Workflow.WorkflowCallConfig() config := rc.Run.Workflow.WorkflowCallConfig()
@@ -548,7 +554,7 @@ func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunCon
} }
} }
(*inputs)[name] = value (*inputs)[name] = coerceInputValue(value, input.Type)
} }
} }
} }

View File

@@ -6,12 +6,14 @@ package runner
import ( import (
"context" "context"
"strings"
"testing" "testing"
"gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
assert "github.com/stretchr/testify/assert" assert "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
yaml "go.yaml.in/yaml/v4" 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"])
})
}
}
}

View File

@@ -138,7 +138,9 @@ func (rc *RunContext) GetEnv() map[string]string {
} }
} }
} }
rc.Env["ACT"] = "true" if !rc.Config.DisableActEnv {
rc.Env["ACT"] = "true"
}
if !rc.Config.NoSkipCheckout { if !rc.Config.NoSkipCheckout {
rc.Env["ACT_SKIP_CHECKOUT"] = "true" rc.Env["ACT_SKIP_CHECKOUT"] = "true"
@@ -337,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 { func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
@@ -379,6 +384,13 @@ func (rc *RunContext) startJobContainer() common.Executor {
// add service containers // add service containers
for serviceID, spec := range rc.Run.Job().Services { 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 // interpolate env
interpolatedEnvs := make(map[string]string, len(spec.Env)) interpolatedEnvs := make(map[string]string, len(spec.Env))
for k, v := range spec.Env { for k, v := range spec.Env {
@@ -393,7 +405,9 @@ func (rc *RunContext) startJobContainer() common.Executor {
for _, v := range spec.Cmd { for _, v := range spec.Cmd {
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v)) 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 { if err != nil {
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err) return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
} }
@@ -414,12 +428,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
} }
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID) serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := container.NewContainer(&container.NewContainerInput{ c := newContainer(&container.NewContainerInput{
Name: serviceContainerName, Name: serviceContainerName,
WorkingDir: ext.ToContainerPath(rc.Config.Workdir), WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: rc.ExprEval.Interpolate(ctx, spec.Image), Image: serviceImage,
Username: username, Username: serviceUsername,
Password: password, Password: servicePassword,
Cmd: interpolatedCmd, Cmd: interpolatedCmd,
Env: envs, Env: envs,
Mounts: serviceMounts, Mounts: serviceMounts,
@@ -475,7 +489,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
// For Gitea, `jobContainerNetwork` should be the same as `networkName` // For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName jobContainerNetwork := networkName
rc.JobContainer = container.NewContainer(&container.NewContainerInput{ rc.JobContainer = newContainer(&container.NewContainerInput{
Cmd: nil, Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir), WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
@@ -783,7 +797,13 @@ func (rc *RunContext) Executor() (common.Executor, error) {
return func(ctx context.Context) error { return func(ctx context.Context) error {
res, err := rc.isEnabled(ctx) res, err := rc.isEnabled(ctx)
if err != nil { 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 return err
} }
if res { if res {

View File

@@ -14,6 +14,7 @@ import (
"testing" "testing"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
@@ -202,6 +203,93 @@ jobs:
assert.Empty(t, password) 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) { func TestRunContext_GetBindsAndMounts(t *testing.T) {
rctemplate := &RunContext{ rctemplate := &RunContext{
Name: "TestRCName", Name: "TestRCName",

View File

@@ -65,6 +65,7 @@ type Config struct {
ArtifactServerAddr string // the address the artifact server binds to ArtifactServerAddr string // the address the artifact server binds to
ArtifactServerPort string // the port the artifact server binds to ArtifactServerPort string // the port the artifact server binds to
NoSkipCheckout bool // do not skip actions/checkout 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 RemoteName string // remote name in local git repo config
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub. ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.

View File

@@ -303,6 +303,7 @@ func TestRunEvent(t *testing.T) {
// services // services
{workdir, "services", "push", "", platforms, secrets}, {workdir, "services", "push", "", platforms, secrets},
{workdir, "services-with-container", "push", "", platforms, secrets}, {workdir, "services-with-container", "push", "", platforms, secrets},
{workdir, "services-empty-image", "push", "", platforms, secrets},
// local remote action overrides // local remote action overrides
{workdir, "local-remote-action-overrides", "push", "", platforms, secrets}, {workdir, "local-remote-action-overrides", "push", "", platforms, secrets},

View File

@@ -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"

View File

@@ -1,6 +1,6 @@
{ {
"inputs": { "inputs": {
"required": "required input", "required": "required input",
"boolean": "true" "boolean": true
} }
} }

View File

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

View File

@@ -1,3 +1,5 @@
# Holds the runner's working directory (/data): the .runner registration file
# and, optionally, the config file.
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
apiVersion: v1 apiVersion: v1
metadata: metadata:
@@ -10,6 +12,21 @@ spec:
storage: 1Gi storage: 1Gi
storageClassName: standard 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 apiVersion: v1
data: data:
# The registration token can be obtained from the web UI, API or command-line. # The registration token can be obtained from the web UI, API or command-line.
@@ -45,6 +62,9 @@ spec:
- name: runner-data - name: runner-data
persistentVolumeClaim: persistentVolumeClaim:
claimName: runner-vol claimName: runner-vol
- name: docker-data
persistentVolumeClaim:
claimName: docker-vol
initContainers: initContainers:
- name: docker - name: docker
image: docker:28.2.2-dind image: docker:28.2.2-dind
@@ -53,6 +73,8 @@ spec:
volumeMounts: volumeMounts:
- name: docker-socket - name: docker-socket
mountPath: /var/run mountPath: /var/run
- name: docker-data
mountPath: /var/lib/docker
startupProbe: startupProbe:
exec: exec:
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"] command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]

View File

@@ -1,3 +1,5 @@
# Holds the runner's working directory (/data): the .runner registration file
# and, optionally, the config file.
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
apiVersion: v1 apiVersion: v1
metadata: metadata:
@@ -10,6 +12,21 @@ spec:
storage: 1Gi storage: 1Gi
storageClassName: standard 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 apiVersion: v1
data: data:
# The registration token can be obtained from the web UI, API or command-line. # The registration token can be obtained from the web UI, API or command-line.
@@ -43,7 +60,12 @@ spec:
- name: runner-data - name: runner-data
persistentVolumeClaim: persistentVolumeClaim:
claimName: runner-vol claimName: runner-vol
- name: docker-data
persistentVolumeClaim:
claimName: docker-vol
securityContext: securityContext:
# The dind-rootless image runs as the `rootless` user (UID/GID 1000);
# fsGroup makes both volumes writable for it.
fsGroup: 1000 fsGroup: 1000
containers: containers:
- name: runner - name: runner
@@ -68,4 +90,7 @@ spec:
volumeMounts: volumeMounts:
- name: runner-data - name: runner-data
mountPath: /data mountPath: /data
# The rootless daemon keeps its images here, not under /data.
- name: docker-data
mountPath: /home/rootless/.local/share/docker

View File

@@ -0,0 +1,96 @@
# 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
# 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"]
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:
# The runner's working directory: the .runner registration file and, optionally,
# the config file.
- metadata:
name: runner-data
spec:
accessModes:
- ReadWriteOnce
resources:
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

View File

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

View File

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

9
go.mod
View File

@@ -29,6 +29,7 @@ require (
github.com/opencontainers/selinux v1.15.1 github.com/opencontainers/selinux v1.15.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2 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/rhysd/actionlint v1.7.12
github.com/sirupsen/logrus v1.9.4 github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
@@ -37,8 +38,9 @@ require (
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
go.etcd.io/bbolt v1.5.0 go.etcd.io/bbolt v1.5.0
go.yaml.in/yaml/v4 v4.0.0-rc.3 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 golang.org/x/term v0.45.0
golang.org/x/text v0.40.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gotest.tools/v3 v3.5.2 gotest.tools/v3 v3.5.2
tags.cncf.io/container-device-interface v1.1.0 tags.cncf.io/container-device-interface v1.1.0
@@ -84,7 +86,6 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pmezard/go-difflib v1.0.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/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
@@ -106,7 +107,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.54.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/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

22
go.sum
View File

@@ -47,10 +47,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+4uf10QRSc=
github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.6.1+incompatible h1:oO7F4nn3Ovr/5TlfTUWFbMwBSS/B7Xs6Epv26gBrUP8= 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.1+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 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
@@ -131,12 +127,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
@@ -215,8 +207,6 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M= go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
@@ -252,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/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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -260,14 +252,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= 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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= 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.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= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

View File

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

View File

@@ -35,7 +35,8 @@ func Execute(ctx context.Context) {
} }
registerCmd.Flags().BoolVar(&regArgs.NoInteractive, "no-interactive", false, "Disable interactive mode") registerCmd.Flags().BoolVar(&regArgs.NoInteractive, "no-interactive", false, "Disable interactive mode")
registerCmd.Flags().StringVar(&regArgs.InstanceAddr, "instance", "", "Gitea instance address") registerCmd.Flags().StringVar(&regArgs.InstanceAddr, "instance", "", "Gitea instance address")
registerCmd.Flags().StringVar(&regArgs.Token, "token", "", "Runner token") registerCmd.Flags().StringVar(&regArgs.Token, "token", "", "Runner token (or set the GITEA_RUNNER_REGISTRATION_TOKEN envvar)")
registerCmd.Flags().StringVar(&regArgs.TokenFile, "token-file", "", "Path to a file containing the runner token")
registerCmd.Flags().StringVar(&regArgs.RunnerName, "name", "", "Runner name") registerCmd.Flags().StringVar(&regArgs.RunnerName, "name", "", "Runner name")
registerCmd.Flags().StringVar(&regArgs.Labels, "labels", "", "Runner tags, comma separated") registerCmd.Flags().StringVar(&regArgs.Labels, "labels", "", "Runner tags, comma separated")
registerCmd.Flags().BoolVar(&regArgs.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)") registerCmd.Flags().BoolVar(&regArgs.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)")
@@ -50,11 +51,15 @@ func Execute(ctx context.Context) {
RunE: runDaemon(ctx, &daemArgs, &configFile), RunE: runDaemon(ctx, &daemArgs, &configFile),
} }
daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit") 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) rootCmd.AddCommand(daemonCmd)
// ./gitea-runner exec // ./gitea-runner exec
rootCmd.AddCommand(loadExecCmd(ctx)) rootCmd.AddCommand(loadExecCmd(ctx))
// ./gitea-runner bug-report
rootCmd.AddCommand(loadBugReportCmd())
// ./gitea-runner config // ./gitea-runner config
rootCmd.AddCommand(&cobra.Command{ rootCmd.AddCommand(&cobra.Command{
Use: "generate-config", Use: "generate-config",

View File

@@ -49,10 +49,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
return fmt.Errorf("failed to load registration file: %w", err) return fmt.Errorf("failed to load registration file: %w", err)
} }
lbls := reg.Labels lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels)
if len(cfg.Runner.Labels) > 0 {
lbls = cfg.Runner.Labels
}
ls := labels.Labels{} ls := labels.Labels{}
for _, l := range lbls { for _, l := range lbls {
@@ -176,7 +173,12 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
} else { } else {
go poller.Poll() 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) log.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Msg.Runner.Name, cfg.Runner.ShutdownTimeout)
@@ -189,12 +191,39 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name) 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 return nil
} }
} }
type daemonArgs struct { 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. // initLogging setup the global logrus logger.

View File

@@ -12,6 +12,32 @@ import (
"github.com/stretchr/testify/require" "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) { func TestGetDockerSocketPathUsesConfigAndEnvironment(t *testing.T) {
got, err := getDockerSocketPath("tcp://docker.example:2376") got, err := getDockerSocketPath("tcp://docker.example:2376")
require.NoError(t, err) require.NoError(t, err)

View File

@@ -34,6 +34,7 @@ type executeArgs struct {
runList bool runList bool
job string job string
event string event string
eventpath string
workdir string workdir string
workflowsPath string workflowsPath string
noWorkflowRecurse bool noWorkflowRecurse bool
@@ -441,6 +442,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
ArtifactServerPort: execArgs.artifactServerPort, ArtifactServerPort: execArgs.artifactServerPort,
ArtifactServerAddr: execArgs.artifactServerAddr, ArtifactServerAddr: execArgs.artifactServerAddr,
NoSkipCheckout: execArgs.noSkipCheckout, NoSkipCheckout: execArgs.noSkipCheckout,
EventPath: execArgs.resolve(execArgs.eventpath),
// PresetGitHubContext: preset, // PresetGitHubContext: preset,
// EventJSON: string(eventJSON), // EventJSON: string(eventJSON),
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName, 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().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.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.workflowsPath, "workflows", "W", "./.gitea/workflows/", "path to workflow file(s)")
execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "working directory") 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") execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "Flag to disable running workflows from subdirectories of specified path in '--workflows'/'-W' flag")

View File

@@ -75,6 +75,7 @@ type registerArgs struct {
NoInteractive bool NoInteractive bool
InstanceAddr string InstanceAddr string
Token string Token string
TokenFile string
RunnerName string RunnerName string
Labels string Labels string
Ephemeral bool Ephemeral bool
@@ -93,6 +94,8 @@ const (
StageExit StageExit
) )
const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN"
var defaultLabels = []string{ var defaultLabels = []string{
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest", "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04", "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
@@ -207,10 +210,27 @@ func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *co
return StageUnknown 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 := &registerInputs{ inputs := &registerInputs{
InstanceAddr: regArgs.InstanceAddr, InstanceAddr: regArgs.InstanceAddr,
Token: regArgs.Token, Token: token,
RunnerName: regArgs.RunnerName, RunnerName: regArgs.RunnerName,
Ephemeral: regArgs.Ephemeral, Ephemeral: regArgs.Ephemeral,
} }
@@ -219,7 +239,7 @@ func initInputs(regArgs *registerArgs) *registerInputs {
if regArgs.Labels != "" { if regArgs.Labels != "" {
inputs.Labels = strings.Split(regArgs.Labels, ",") inputs.Labels = strings.Split(regArgs.Labels, ",")
} }
return inputs return inputs, nil
} }
func registerInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error { 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() { if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() {
stage = StageOverwriteLocalConfig stage = StageOverwriteLocalConfig
} }
inputs := initInputs(regArgs) inputs, err := initInputs(regArgs)
if err != nil {
return err
}
for { for {
cmdString := inputs.stageValue(stage) cmdString := inputs.stageValue(stage)
@@ -292,7 +315,10 @@ func registerNoInteractive(ctx context.Context, configFile string, regArgs *regi
if err != nil { if err != nil {
return err return err
} }
inputs := initInputs(regArgs) inputs, err := initInputs(regArgs)
if err != nil {
return err
}
// specify labels in config file. // specify labels in config file.
if len(cfg.Runner.Labels) > 0 { if len(cfg.Runner.Labels) > 0 {
if regArgs.Labels != "" { if regArgs.Labels != "" {
@@ -361,7 +387,10 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
ls := make([]string, len(reg.Labels)) ls := make([]string, len(reg.Labels))
for i, v := range 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 ls[i] = l.Name
} }
// register new runner. // register new runner.

View File

@@ -15,11 +15,11 @@ import (
func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) { func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
err := registerNoInteractive(t.Context(), "", &registerArgs{ err := registerNoInteractive(t.Context(), "", &registerArgs{
Labels: "label:invalid", Labels: "ubuntu:host,,broken",
Token: "token", Token: "token",
InstanceAddr: "http://localhost:3000", InstanceAddr: "http://localhost:3000",
}) })
assert.Error(t, err, "unsupported schema: invalid") assert.ErrorContains(t, err, "empty label")
} }
func TestRegisterInputsValidate(t *testing.T) { func TestRegisterInputsValidate(t *testing.T) {
@@ -40,8 +40,8 @@ func TestRegisterInputsValidate(t *testing.T) {
}, },
{ {
name: "invalid label", name: "invalid label",
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:vm:bad"}}, inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{""}},
wantErr: "unsupported schema: vm", wantErr: "empty label",
}, },
{ {
name: "valid", name: "valid",
@@ -62,7 +62,9 @@ func TestRegisterInputsValidate(t *testing.T) {
func TestValidateLabels(t *testing.T) { func TestValidateLabels(t *testing.T) {
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"})) 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) { 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) { t.Run("labels from config skip the labels stage", func(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
cfg.Runner.Labels = []string{"ubuntu:host", "ubuntu:vm:bad"} cfg.Runner.Labels = []string{"ubuntu:host", "", "pool:e57e18d4"}
inputs := &registerInputs{} inputs := &registerInputs{}
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg)) require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg))
// only the valid label survives require.Equal(t, []string{"ubuntu:host", "pool:e57e18d4"}, inputs.Labels)
require.Equal(t, []string{"ubuntu:host"}, inputs.Labels)
}) })
t.Run("blank labels input uses defaults", func(t *testing.T) { 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) { t.Run("invalid labels input loops back", func(t *testing.T) {
inputs := &registerInputs{} inputs := &registerInputs{}
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) require.Nil(t, inputs.Labels)
}) })
t.Run("labels containing a colon are accepted", func(t *testing.T) {
inputs := &registerInputs{}
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) { t.Run("overwrite local config", func(t *testing.T) {
inputs := &registerInputs{} inputs := &registerInputs{}
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg)) require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg))
@@ -139,18 +146,103 @@ func TestRegisterInputsAssignToNext(t *testing.T) {
} }
func TestInitInputs(t *testing.T) { func TestInitInputs(t *testing.T) {
inputs := initInputs(&registerArgs{ t.Run("missing token", func(t *testing.T) {
InstanceAddr: "http://localhost:3000", _, err := initInputs(&registerArgs{
Token: "token", InstanceAddr: "http://localhost:3000",
RunnerName: "runner", RunnerName: "runner",
Ephemeral: true, Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ", 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(&registerArgs{Labels: " "}).Labels) t.Run("empty token", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "")
_, err := initInputs(&registerArgs{
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(&registerArgs{
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(&registerArgs{
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(&registerArgs{
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(&registerArgs{
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(&registerArgs{
Token: "from-plain-arg",
Labels: " ",
})
require.Nil(t, inputs.Labels)
})
} }

View File

@@ -45,6 +45,10 @@ type Poller struct {
shutdownJobs context.CancelFunc shutdownJobs context.CancelFunc
done chan struct{} 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 // 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() { func (p *Poller) runIdleMaintenance() {
if idleRunner, ok := p.runner.(IdleRunner); ok { if idleRunner, ok := p.runner.(IdleRunner); ok {
idleRunner.OnIdle(p.jobsCtx) 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()) metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
if err != nil { 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") log.WithError(err).Error("failed to fetch task")
s.consecutiveErrors++ s.consecutiveErrors++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc() metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()

View File

@@ -78,6 +78,35 @@ func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
assert.Equal(t, int64(0), s.consecutiveEmpty) 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 // TestPoller_CalculateInterval verifies the exponential backoff math is
// correctly driven by the workerState counters. // correctly driven by the workerState counters.
func TestPoller_CalculateInterval(t *testing.T) { func TestPoller_CalculateInterval(t *testing.T) {

View File

@@ -366,7 +366,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
} else if t := task.Secrets["GITHUB_TOKEN"]; t != "" { } else if t := task.Secrets["GITHUB_TOKEN"]; t != "" {
preset.Token = t preset.Token = t
} }
applyPullRequestTargetCheckoutContext(preset)
if actionsIDTokenRequestURL := taskContext["actions_id_token_request_url"].GetStringValue(); actionsIDTokenRequestURL != "" { if actionsIDTokenRequestURL := taskContext["actions_id_token_request_url"].GetStringValue(); actionsIDTokenRequestURL != "" {
envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = actionsIDTokenRequestURL envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = actionsIDTokenRequestURL
@@ -446,6 +445,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"), GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
AutoRemove: true, AutoRemove: true,
NoSkipCheckout: true, NoSkipCheckout: true,
DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv,
PresetGitHubContext: preset, PresetGitHubContext: preset,
EventJSON: string(eventJSON), EventJSON: string(eventJSON),
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id), ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
@@ -575,41 +575,6 @@ func postInternalCache(url, secret string, body map[string]string) error {
return nil return nil
} }
func applyPullRequestTargetCheckoutContext(preset *model.GithubContext) {
if preset == nil || preset.EventName != "pull_request_target" {
return
}
headSHA := nestedString(preset.Event, "pull_request", "head", "sha")
if headSHA == "" {
return
}
preset.Sha = headSHA
preset.Ref = headSHA
if headRef := nestedString(preset.Event, "pull_request", "head", "ref"); headRef != "" {
preset.HeadRef = headRef
preset.RefName = headRef
}
}
func nestedString(m map[string]any, keys ...string) string {
var current any = m
for _, key := range keys {
next, ok := current.(map[string]any)
if !ok {
return ""
}
current, ok = next[key]
if !ok {
return ""
}
}
value := current.(string)
if value == "" {
return ""
}
return value
}
func (r *Runner) RunningCount() int64 { func (r *Runner) RunningCount() int64 {
return r.runningCount.Load() return r.runningCount.Load()
} }

View File

@@ -7,7 +7,6 @@ import (
"context" "context"
"testing" "testing"
"gitea.com/gitea/runner/act/model"
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks" clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/ver" "gitea.com/gitea/runner/internal/pkg/ver"
@@ -76,7 +75,7 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
cfg.Runner.Envs = map[string]string{"EXISTING": "value"} cfg.Runner.Envs = map[string]string{"EXISTING": "value"}
reg := &config.Registration{ reg := &config.Registration{
Name: "runner", Name: "runner",
Labels: []string{"ubuntu:host", "bad:vm:label"}, Labels: []string{"ubuntu:host", "", "pool:e57e18d4"},
} }
cli := clientmocks.NewClient(t) cli := clientmocks.NewClient(t)
cli.On("Address").Return("https://gitea.example/").Maybe() cli.On("Address").Return("https://gitea.example/").Maybe()
@@ -84,7 +83,8 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
r := NewRunner(cfg, reg, cli) r := NewRunner(cfg, reg, cli)
require.Equal(t, "runner", r.name) 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, "value", r.envs["EXISTING"])
require.Equal(t, "https://gitea.example/api/actions_pipeline/", r.envs["ACTIONS_RUNTIME_URL"]) 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"]) require.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
@@ -93,67 +93,6 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
require.Nil(t, r.cacheHandler) require.Nil(t, r.cacheHandler)
} }
func TestApplyPullRequestTargetCheckoutContextUsesHeadSHA(t *testing.T) {
preset := &model.GithubContext{
EventName: "pull_request_target",
Sha: "base-sha",
Ref: "refs/heads/main",
RefName: "main",
HeadRef: "feature",
Event: map[string]any{
"pull_request": map[string]any{
"head": map[string]any{
"sha": "head-sha",
"ref": "contributor-branch",
},
},
},
}
applyPullRequestTargetCheckoutContext(preset)
require.Equal(t, "head-sha", preset.Sha)
require.Equal(t, "head-sha", preset.Ref)
require.Equal(t, "contributor-branch", preset.RefName)
require.Equal(t, "contributor-branch", preset.HeadRef)
}
func TestApplyPullRequestTargetCheckoutContextNoOpsWithoutHeadSHA(t *testing.T) {
preset := &model.GithubContext{
EventName: "pull_request_target",
Sha: "base-sha",
Ref: "refs/heads/main",
RefName: "main",
Event: map[string]any{},
}
applyPullRequestTargetCheckoutContext(preset)
require.Equal(t, "base-sha", preset.Sha)
require.Equal(t, "refs/heads/main", preset.Ref)
require.Equal(t, "main", preset.RefName)
}
func TestApplyPullRequestTargetCheckoutContextNoOpsForOtherEvents(t *testing.T) {
preset := &model.GithubContext{
EventName: "pull_request",
Sha: "merge-sha",
Ref: "refs/pull/1/merge",
Event: map[string]any{
"pull_request": map[string]any{
"head": map[string]any{
"sha": "head-sha",
},
},
},
}
applyPullRequestTargetCheckoutContext(preset)
require.Equal(t, "merge-sha", preset.Sha)
require.Equal(t, "refs/pull/1/merge", preset.Ref)
}
func taskWithDefaultActionsURL(url string) *runnerv1.Task { func taskWithDefaultActionsURL(url string) *runnerv1.Task {
return &runnerv1.Task{ return &runnerv1.Task{
Context: &structpb.Struct{ Context: &structpb.Struct{

View File

@@ -10,6 +10,8 @@ import (
"strings" "strings"
"time" "time"
"gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect" "connectrpc.com/connect"
"gitea.dev/actions-proto-go/ping/v1/pingv1connect" "gitea.dev/actions-proto-go/ping/v1/pingv1connect"
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect" "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 { opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
req.Header().Set("User-Agent", "gitea-runner/"+ver.Version())
if uuid != "" { if uuid != "" {
req.Header().Set(UUIDHeader, uuid) req.Header().Set(UUIDHeader, uuid)
} }

View File

@@ -72,6 +72,9 @@ runner:
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history. # When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
# Set to false to clone the full history. # Set to false to clone the full history.
action_shallow_clone: true action_shallow_clone: true
# When true (the default), inject the ACT=true environment variable into jobs.
# 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. # The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" # Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images . # Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
@@ -110,6 +113,11 @@ cache:
dir: "" dir: ""
# Outbound IP or hostname that job containers use to reach this runner's cache server. # Outbound IP or hostname that job containers use to reach this runner's cache server.
# Leave empty to detect automatically. 0.0.0.0 is not valid here. # Leave empty to detect automatically. 0.0.0.0 is not valid here.
# If the runner itself runs in Docker, automatic detection can choose an
# 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. # Ignored when external_server is set.
host: "" host: ""
# Port for the built-in cache server. 0 picks a random free port. # Port for the built-in cache server. 0 picks a random free port.
@@ -133,6 +141,8 @@ container:
# Specifies the network to which the container will connect. # Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network. # Could be host, bridge or the name of a custom network.
# If it's empty, runner will create a network automatically. # 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. # Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
network: "" network: ""
# network_create_options only apply when `network` is left empty and the runner # network_create_options only apply when `network` is left empty and the runner
@@ -168,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 "-", runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work. # If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
docker_host: "" docker_host: ""
# Pull docker image(s) even if already present # Pull docker image(s) even if already present.
force_pull: true # Defaults to false when the key is omitted.
force_pull: false
# Rebuild docker image(s) even if already present # Rebuild docker image(s) even if already present
force_rebuild: false force_rebuild: false
# Always require a reachable docker daemon, even if not required by runner # Always require a reachable docker daemon, even if not required by runner

View File

@@ -49,6 +49,7 @@ type Runner struct {
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup 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 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. 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. 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. 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. 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 b := true
cfg.Runner.ActionShallowClone = &b cfg.Runner.ActionShallowClone = &b
} }
if cfg.Runner.SetActEnv == nil {
b := true
cfg.Runner.SetActEnv = &b
}
if cfg.Cache.Enabled == nil { if cfg.Cache.Enabled == nil {
b := true b := true
cfg.Cache.Enabled = &b cfg.Cache.Enabled = &b
} }
if *cfg.Cache.Enabled { if *cfg.Cache.Enabled {
if cfg.Cache.Dir == "" { 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") cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
} }
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" { if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
@@ -173,7 +181,10 @@ func LoadDefault(file string) (*Config, error) {
cfg.Container.WorkdirParent = "workspace" cfg.Container.WorkdirParent = "workspace"
} }
if cfg.Host.WorkdirParent == "" { 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") cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
} }
if cfg.Runner.FetchTimeout <= 0 { if cfg.Runner.FetchTimeout <= 0 {

View File

@@ -4,7 +4,7 @@
package labels package labels
import ( import (
"fmt" "errors"
"strings" "strings"
) )
@@ -17,13 +17,20 @@ type Label struct {
Name string Name string
Schema string Schema string
Arg 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) { func Parse(str string) (*Label, error) {
if str == "" {
return nil, errors.New("empty label")
}
splits := strings.SplitN(str, ":", 3) splits := strings.SplitN(str, ":", 3)
label := &Label{ label := &Label{
Name: splits[0], Name: splits[0],
Schema: "host", Schema: SchemeHost,
Arg: "", Arg: "",
} }
if len(splits) >= 2 { if len(splits) >= 2 {
@@ -33,7 +40,12 @@ func Parse(str string) (*Label, error) {
label.Arg = splits[2] label.Arg = splits[2]
} }
if label.Schema != SchemeHost && label.Schema != SchemeDocker { 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 return label, nil
} }
@@ -59,7 +71,7 @@ func (l Labels) PickPlatform(runsOn []string) string {
case SchemeHost: case SchemeHost:
platforms[label.Name] = "-self-hosted" platforms[label.Name] = "-self-hosted"
default: default:
// It should not happen, because Parse has checked it. // unreachable: Parse only produces host or docker schemas
continue continue
} }
} }
@@ -94,7 +106,7 @@ func (l Labels) ToStrings() []string {
ls := make([]string, 0, len(l)) ls := make([]string, 0, len(l))
for _, label := range l { for _, label := range l {
lbl := label.Name lbl := label.Name
if label.Schema != "" { if !label.Opaque && label.Schema != "" {
lbl += ":" + label.Schema lbl += ":" + label.Schema
if label.Arg != "" { if label.Arg != "" {
lbl += ":" + label.Arg lbl += ":" + label.Arg

View File

@@ -44,7 +44,27 @@ func TestParse(t *testing.T) {
wantErr: false, 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, want: nil,
wantErr: true, wantErr: true,
}, },
@@ -116,8 +136,8 @@ func TestPickPlatform(t *testing.T) {
} }
func TestNames(t *testing.T) { func TestNames(t *testing.T) {
ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host") ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host", "pool:e57e18d4")
require.Equal(t, []string{"ubuntu", "self-hosted"}, ls.Names()) require.Equal(t, []string{"ubuntu", "self-hosted", "pool:e57e18d4"}, ls.Names())
require.Empty(t, Labels{}.Names()) require.Empty(t, Labels{}.Names())
} }
@@ -126,10 +146,26 @@ func TestToStrings(t *testing.T) {
"ubuntu:docker://node:18", "ubuntu:docker://node:18",
"self-hosted:host", "self-hosted:host",
"bare", "bare",
"pool:e57e18d4",
) )
require.Equal(t, []string{ require.Equal(t, []string{
"ubuntu:docker://node:18", "ubuntu:docker://node:18",
"self-hosted:host", "self-hosted:host",
"bare:host", "bare:host",
"pool:e57e18d4",
}, ls.ToStrings()) }, 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}))
}

View File

@@ -26,6 +26,12 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "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 { type Reporter struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -390,13 +396,15 @@ func (r *Reporter) SetOutputs(outputs map[string]string) {
defer r.stateMu.Unlock() defer r.stateMu.Unlock()
for k, v := range outputs { for k, v := range outputs {
if len(k) > 255 { if l := len(k); l > maxOutputKeyLen {
r.logf("ignore output because the key is too long: %q", k) 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 continue
} }
if l := len(v); l > 1024*1024 { if l := len(v); l > maxOutputValueLen {
log.Println("ignore output because the value is too long:", k, l) log.Warnf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen)
r.logf("ignore output because the value %q is too long: %d", k, l) r.logf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen)
continue
} }
if _, ok := r.outputs.Load(k); ok { if _, ok := r.outputs.Load(k); ok {
continue continue

View File

@@ -1020,11 +1020,24 @@ func TestReporter_SetOutputs(t *testing.T) {
got, _ = r.outputs.Load("foo") got, _ = r.outputs.Load("foo")
assert.Equal(t, "bar", got) assert.Equal(t, "bar", got)
// keys longer than 255 chars are dropped // keys longer than maxOutputKeyLen are dropped
longKey := strings.Repeat("k", 256) longKey := strings.Repeat("k", maxOutputKeyLen+1)
r.SetOutputs(map[string]string{longKey: "v"}) r.SetOutputs(map[string]string{longKey: "v"})
_, ok = r.outputs.Load(longKey) _, ok = r.outputs.Load(longKey)
assert.False(t, ok) 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) { func TestReporter_EffectiveCloseTimeout(t *testing.T) {

View File

@@ -12,14 +12,18 @@ CONFIG_ARG=""
if [[ ! -z "${CONFIG_FILE}" ]]; then if [[ ! -z "${CONFIG_FILE}" ]]; then
CONFIG_ARG="--config ${CONFIG_FILE}" CONFIG_ARG="--config ${CONFIG_FILE}"
fi fi
EXTRA_ARGS="" LABEL_ARGS=""
if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then
EXTRA_ARGS="${EXTRA_ARGS} --labels ${GITEA_RUNNER_LABELS}" LABEL_ARGS="--labels ${GITEA_RUNNER_LABELS}"
fi fi
EXTRA_ARGS="${LABEL_ARGS}"
if [[ ! -z "${GITEA_RUNNER_EPHEMERAL}" ]]; then if [[ ! -z "${GITEA_RUNNER_EPHEMERAL}" ]]; then
EXTRA_ARGS="${EXTRA_ARGS} --ephemeral" EXTRA_ARGS="${EXTRA_ARGS} --ephemeral"
fi 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 if [[ ! -z "${GITEA_RUNNER_ONCE}" ]]; then
RUN_ARGS="${RUN_ARGS} --once" RUN_ARGS="${RUN_ARGS} --once"
fi fi