## Summary A nested `uses:` action inside a **local composite action** fails to clone with a bare `authentication required: Unauthorized` (401) when the instance resolves host-less action references against itself (`DEFAULT_ACTIONS_URL = self`). The job fails at the composite **main→post boundary**, even though the composite's own steps and all post-steps report success. ## Root cause `newCompositeRunContext` nils `Config.Secrets` so composite steps do not see job secrets. But the action-clone path in `prepareActionExecutor` sourced its token via `getGitCloneToken` → `Config.GetToken()` → `Config.Secrets`, which is empty inside a composite RunContext. The nested action is therefore cloned anonymously → 401 against the authenticated instance. Two details explain the exact symptom: - It surfaces at the composite **main→post boundary** because the swallowed nested-step error is re-emitted by `common.JobError` at the end of the composite main pipeline. - It carries **no clone URL** because, on a warm action cache, only `r.Fetch` runs (not `PlainClone`), and the go-git fetch error is returned verbatim. ## Fix Source the clone token from `github.Token` instead of `Config.Secrets`. It is preserved across the composite config copy (`Config.Token` / `PresetGitHubContext`) and is identical to `Config.GetToken()` at the top level, so top-level and `act exec` behaviour is unchanged. The `shouldCloneURLUseToken` host gate is kept so the token is never sent to a foreign host. This also aligns the git-clone path with the ActionCache fetch path, which already uses `github.Token`. Reusable workflows are unaffected — their RunContext keeps `Config.Secrets`. ## Before / after Local composite action with a nested `uses:` (+ post step), followed by a marker step. Same workflow, same runner host — only the runner fix differs. | Job step | Before fix | After fix | | --- | --- | --- | | `Run actions/checkout@v6` | ✅ success | ✅ success | | `Run ./.gitea/actions/probe-composite` (nested `uses:` + post) | ❌ **failure** — bare 401 at the main→post boundary | ✅ success | | `Step after composite` | ⊘ **skipped** | ✅ **ran** | | Job result | ❌ **failed** | ✅ **succeeded** | ### Before — boundary log ```text ::endgroup:: ##[error]authentication required: Unauthorized ← bare 401, no clone target Run Post ./.gitea/actions/probe-composite Success - Post ./.gitea/actions/probe-composite ← post steps run and succeed Success - Post actions/checkout@v6 Job failed ``` The composite's own steps and both post-steps report `Success`; the job fails solely on the bare 401 emitted at the main→post boundary, and the next step is skipped. ### After — boundary log ```text Run ./.gitea/actions/probe-composite ...composite steps... Success - ./.gitea/actions/probe-composite Run Marker after composite PASSED composite boundary — no 401 (runner fix confirmed) Success - Marker after composite Job succeeded ``` ## Reproduction `.gitea/actions/probe-composite/action.yml`: ```yaml name: probe-composite runs: using: composite steps: - uses: actions/setup-node@v6 # nested uses → has a post step with: { node-version: 22 } - run: echo "composite inner step OK" shell: bash ``` `.gitea/workflows/probe.yaml`: ```yaml on: [push] jobs: composite-boundary: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ./.gitea/actions/probe-composite - run: echo "step after composite" # skipped before the fix; runs after ``` ## Verification - **Before:** bare 401 at the boundary, reproduced with a `delay=0` tail — rules out a token-TTL / expiry effect; it is a missing credential. - **After:** the composite step succeeds, the step after the composite runs, the job succeeds, and there is no `authentication required` in the log. - A reusable workflow (`workflow_call`) with the same nested `uses:` + post step never hit the 401, which isolated the bug to the composite main/post path. ## Tests Adds `TestStepActionRemoteCloneTokenSurvivesNilSecrets` (`act/runner`): asserts the clone token is forwarded when the RunContext mirrors a composite (`Secrets == nil`, token via `Config.Token`), and that the host gate still withholds the token for a foreign host. Verified to fail without the fix and pass with it. --------- Reviewed-on: https://gitea.com/gitea/runner/pulls/1041 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> Co-authored-by: Christian Heim <christian@heimdaheim.de> Co-committed-by: Christian Heim <christian@heimdaheim.de> |
||
|---|---|---|
| .gitea/workflows | ||
| act | ||
| docs | ||
| examples | ||
| internal | ||
| scripts | ||
| tools | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitattributes | ||
| .gitignore | ||
| .golangci.yml | ||
| .goreleaser.checksum.sh | ||
| .goreleaser.yaml | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| main.go | ||
| Makefile | ||
| README.md | ||
| renovate.json5 | ||
Gitea Runner
Installation
Prerequisites
Docker Engine Community version is required for docker mode. To install Docker CE, follow the official install instructions.
Download pre-built binary
Visit here and download the right version for your platform.
Build from source
make build
Build a docker image
make docker
Quickstart
Actions are disabled by default, so you need to add the following to the configuration file of your Gitea instance to enable it:
[actions]
ENABLED=true
Register
./gitea-runner register
And you will be asked to input:
- Gitea instance URL, like
http://192.168.8.8:3000/. You should use your gitea instance ROOT_URL as the instance argument and you should not uselocalhostor127.0.0.1as instance IP; - Runner token, you can get it from
http://192.168.8.8:3000/admin/actions/runners; - Runner name, you can just leave it blank;
- Runner labels, you can just leave it blank.
The process looks like:
INFO Registering runner, arch=amd64, os=darwin, version=0.1.5.
WARN Runner in user-mode.
INFO Enter the Gitea instance URL (for example, https://gitea.com/):
http://192.168.8.8:3000/
INFO Enter the runner token:
fe884e8027dc292970d4e0303fe82b14xxxxxxxx
INFO Enter the runner name (if set empty, use hostname: Test.local):
INFO Enter the runner labels, leave blank to use the default labels (comma-separated, for example, ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest):
INFO Registering runner, name=Test.local, instance=http://192.168.8.8:3000/, labels=[ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04 ubuntu-20.04:docker://docker.gitea.com/runner-images:ubuntu-20.04].
DEBU Successfully pinged the Gitea instance server
INFO Runner registered successfully.
You can also register with command line arguments.
./gitea-runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
If the registry succeed, it will run immediately. Next time, you could run the runner directly.
Run
./gitea-runner daemon
Run with docker
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:nightly
Mount a volume on /data if you want the registration file and optional config to survive container recreation (see scripts/run.sh).
Image flavours
The image is published in three flavours, all built from the single multi-stage 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.
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
|---|---|---|---|---|---|
latest (and <version>) |
basic |
alpine |
none — uses an external daemon you provide | tini |
root |
latest-dind |
dind |
docker:dind |
bundled, started inside the container | s6 |
root (privileged) |
latest-dind-rootless |
dind-rootless |
docker:dind-rootless |
bundled, started rootless inside the container | s6 |
rootless (UID 1000) |
latest — basic
The default flavour ships only the runner on a minimal Alpine base. It contains no Docker daemon of its own: jobs that use docker:// images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
tini is the entrypoint (it reaps zombie processes), and it just runs scripts/run.sh, which registers the runner on first start and then execs gitea-runner daemon. This flavour does not need --privileged. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
latest-dind — Docker-in-Docker
This flavour is based on the official docker:dind image and bundles its own Docker daemon, so it needs no external socket — only the --privileged flag that Docker-in-Docker requires:
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
--name my_runner gitea/runner:latest-dind
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the s6 supervision tree under scripts/s6 instead of tini. s6 starts dockerd, and the runner service waits for the daemon to come up (s6-svwait) before launching run.sh. Each container has a private daemon isolated from the host's, at the cost of running privileged.
latest-dind-rootless — rootless Docker-in-Docker
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).
Note on Podman: these images target the Docker daemon. The bundled
dind/dind-rootlessdaemons aredockerd, not Podman, and thebasicflavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing thebasicflavour at a Podman socket that emulates the Docker API may work for some workloads.
Configuration
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
./gitea-runner generate-config > config.yaml
Pass it with -c / --config on any command that loads configuration (register, daemon, cache-server):
./gitea-runner -c config.yaml register
./gitea-runner -c config.yaml daemon
./gitea-runner -c config.yaml cache-server
Every option is described in config.example.yaml (the same content generate-config prints).
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:
| Variable | Effect |
|---|---|
GITEA_DEBUG |
If true, sets log level to debug |
GITEA_TRACE |
If true, sets log level to trace |
GITEA_RUNNER_CAPACITY |
Concurrent jobs (integer) |
GITEA_RUNNER_FILE |
Registration state file path (default .runner) |
GITEA_RUNNER_ENVIRON |
Extra job env vars as comma-separated KEY:VALUE pairs |
GITEA_RUNNER_ENV_FILE |
Path to an env file merged into job env (same idea as runner.env_file in YAML) |
Prefer a YAML file for all settings.
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.
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.
Shared cache across multiple runners
Run one dedicated gitea-runner cache-server that all runners point at.
-
Create a config file for the cache server host:
cache: dir: /data/actcache port: 8088 external_secret: "replace-with-a-strong-random-secret" -
Start the server:
gitea-runner -c cache-server-config.yaml cache-server -
On every runner:
cache: external_server: "http://<cache-server-host>:8088/" external_secret: "replace-with-a-strong-random-secret" # must match the server
Alternatively, mount the same NFS/CIFS share on every runner and point cache.dir at it — simpler, but with weaker isolation between repositories.
S3 / MinIO — mount object storage as a FUSE filesystem (e.g. s3fs or goofys) and set cache.dir to the mount point.
Flags --dir, --host, and --port on cache-server override the corresponding cache.* YAML keys; all other settings, including external_secret, require the config file.
Official Docker image
Besides GITEA_INSTANCE_URL and GITEA_RUNNER_REGISTRATION_TOKEN, the image entrypoint supports optional variables such as CONFIG_FILE (passed through as -c), GITEA_RUNNER_LABELS, GITEA_RUNNER_EPHEMERAL, GITEA_RUNNER_ONCE, GITEA_RUNNER_NAME, GITEA_MAX_REG_ATTEMPTS, RUNNER_STATE_FILE, and GITEA_RUNNER_REGISTRATION_TOKEN_FILE. See scripts/run.sh for exact behavior.
For a fuller container-oriented walkthrough, see examples/docker.
When container.bind_workdir is enabled, stale task workspace directories can be cleaned while the runner is idle:
- directories older than
runner.workdir_cleanup_ageare removed (default:24h; set0to disable) - cleanup runs every
runner.idle_cleanup_interval(default:10m; set0to disable) - only purely numeric subdirectories under
container.workdir_parentare treated as task workspaces and may be removed - cleanup assumes
container.workdir_parentis not shared across multiple runners
Post-task script (runner.post_task_script)
Optional host script that runs after each task's built-in cleanup (post-steps, container teardown, bind-workdir removal). Use it for extra machine housekeeping — Docker pruning, disk cleanup, and similar.
While the script runs, the runner stops task heartbeats and stays offline from Gitea's perspective until the script exits (or hits runner.post_task_script_timeout, default 5m). A script that blocks without exiting keeps the runner from taking new work for up to that timeout. Script output goes to the runner log, not the job log; a non-zero exit is warned but does not change the job result.
On Windows, use .exe, .bat, or .cmd paths; PowerShell (.ps1) is not supported yet as the configured path — wrap commands in a .cmd file instead.
See docs/post-task-script.md for lifecycle details, environment variables, timeout interaction, and platform notes.
Example Deployments
Check out the examples directory for sample deployment types.