Allow `valid_volumes` entries ending in `:ro` to permit only read-only mounts, so a workflow can no longer mount writable a host path the runner only shares read-only, like a certificate store. `**:ro` allows any volume read-only. An entry without `:ro` that matches too still allows writable mounts.
A named volume allowed only through `:ro` is mounted with `nocopy`, because Docker otherwise fills an empty volume from the image even when it is mounted read-only.
Fixes: https://gitea.com/gitea/runner/issues/407
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1272
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
Support tmpfs in container `options` via `--mount type=tmpfs,...`, like GitHub does. The `valid_volumes` check silently dropped these mounts because they have no source. A tmpfs is backed by memory and exposes nothing from the host, same as `--tmpfs`, which already passes. Every other mount still goes through the allowlist.
Both forms now work:
```yaml
services:
mysql:
image: mysql:8.4
options: --tmpfs /var/lib/mysql # already worked
```
```yaml
services:
mysql:
image: mysql:8.4
options: --mount type=tmpfs,destination=/var/lib/mysql # was dropped, now works
```
Reviewed-on: https://gitea.com/gitea/runner/pulls/1289
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
The Kubernetes backend extracted CopyDir archives at "/" with entries like
"var/run/act/actions/<sha>/...". Ubuntu's patched tar refuses to extract
through an absolute symlink, failing with "Invalid cross-device link" on
images that link /var/run to /run, so actions never reached the job
container. Extract at the destination with relative entries instead, like
Copy already does.
This is a similar issue to #1128, but for k8s instead of Docker.
I built the image and deployed it to my k8s cluster to verify the fix.
---------
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1286
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Drew Inglis <drew@drewinglis.com>
- Docker actions evaluate `runs.args` and `runs.env` with their own `inputs`, so a same-named `workflow_dispatch` input no longer replaces the step's `with:` value
- Dockerfile action images are tagged per step and job and removed afterwards, so changed Dockerfiles rebuild and concurrent jobs never share an image
- Workflow and job `env` are evaluated before job and service container credentials and service `env` use them
- Docker actions in host jobs get the host workspace and file-command directory mounted, and no longer inherit the runner's own `PATH`
- Cache server address detection accepts a single interface address
Fixes: https://gitea.com/gitea/runner/issues/390
Fixes: https://gitea.com/gitea/runner/issues/406
Fixes: https://gitea.com/gitea/runner/issues/578
Related: https://gitea.com/gitea/runner/issues/64
Reviewed-on: https://gitea.com/gitea/runner/pulls/1268
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Adds a Kubernetes backend. A `name:kubernetes://image` label runs the job as one pod through the Kubernetes API, with services as containers of the same pod. Step env is exported by a script sent over exec stdin, keeping it out of the pod spec and process arguments, and service env goes into a per-job Secret. Docker actions and container options are rejected. See `docs/kubernetes.md`.
Operators shape job pods with `kubernetes.pod_template`, with per-label `pod_templates` merged in `runs-on` order and a `$services` entry for service containers. The idle cleanup removes pods and Secrets a runner left behind.
The client calls the Kubernetes API directly and runs step commands over `coder/websocket` (same dependency used in gitea) instead of pulling in the heavyweight `kubernetes/client-go`. Includes an e2e test against k3s.
Replaces: https://gitea.com/gitea/runner/pulls/1000
Fixes: https://gitea.com/gitea/runner/issues/31
Assisted-by: Claude Code:claude-opus-5-5
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1260
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
1. Host mode is selected only through `runs-on` again, as in upstream act.
1. A workflow volume that `valid_volumes` drops no longer displaces the runner's own socket, workspace, tool cache or `/var/run/act` mount, so `/var/run/docker.sock:/var/run/docker.sock` works on hosts whose daemon socket lives elsewhere.
1. `docker://` is stripped from job and service images, as on GitHub.
1. Each created service's log is printed at job end, and service log dumps no longer run workflow commands, as on GitHub.
1. A `--privileged` dropped because privileged mode is off now logs a warning.
1. README: the proxy variables change per step through `env:` or for the rest of a job through `$GITHUB_ENV`, `container.env` does not override them.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1253
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Make cache requests go through the runner and remove outbound traffic from the cache server. The runner forwards them to `cache.external_server` when one is set, and attaches itself to each job network so jobs can reach it.
Benefits:
1. Jobs need no http proxy rules for the cache server, which `@actions/http-client` mishandles for CIDR ranges in `no_proxy` (https://github.com/actions/toolkit/pull/2495).
2. Only runners reach a shared cache server, which needs no route to Gitea.
3. A runner container sharing the host Docker socket now caches, where jobs previously could not reach it.
Breaks:
1. Jobs that reach `external_server` but not their runner, such as a remote `DOCKER_HOST`. The built-in cache has always needed that reachability, so both modes now agree. Set `cache.host` and `cache.port` to an address those jobs reach.
2. Jobs on `container.network: bridge`, where Docker rejects the alias, so the runner cannot attach and their cache fails as it does today. Leave `container.network` empty, or name a user-defined network.
*Written by Claude.*
---------
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1229
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
`valid_volumes` patterns now match with doublestar instead of gobwas/glob, which was compiled without separators so `*` crossed slashes. doublestar is already in the module graph, keeps `*` within one path segment and matches host paths with the OS separator, which fixes Windows patterns where a backslash was read as an escape.
Breaking: a host pattern that relied on `*` matching nested paths needs `**`, e.g. `/data/**` instead of `/data/*`. Named volumes and the bare `**` are unaffected.
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1214
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Re-sync `act/container/docker_cli.go` and `act/container/docker_cli_test.go` to docker/cli v29.8.1, the version go.mod pins. The copies lacked the `--umask` flag added in v29.8.0, so `container.options` rejected it as unknown. Daemons below API 1.56 ignore the setting.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1238
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Podman creates a missing bind source instead of rejecting it, so the Docker proxy probe passed even when the daemon could not see the runner's files. Jobs of Podman runners in a container got an empty directory at `/var/run/docker.sock` (https://gitea.com/gitea/runner/issues/1193#issuecomment-1700501).
The probe now binds the directory and stats its marker through the created container. A runner in a container stats the marker through its own container, so it no longer creates probe containers, which cost up to 2s per job. `make test-dind TARGET=podman` runs the probe against Podman in CI.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1231
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Containers a job starts through its Docker socket, for example `docker run -v "$PWD:/src"`, `./data:/data` in docker compose, or actions like dockerfile-roast, can now bind-mount the workspace and other paths the job sees, as on a host, without `bind_workdir`.
The per-job Docker proxy rewrites container and volume create requests. A bind source, or the device of a `local` volume with `o: bind`, that lies under one of the job container's mounts is pointed at that mount's path on the daemon, read from inspecting the job container. Paths that already name a daemon path, like `GITEA_DOCKER_WORKSPACE`, and paths outside the job's mounts pass through unchanged.
The proxy now also starts when the runner runs in a container given the host's Docker socket, by placing its socket in the runner's working directory, and in rootless dind, by granting the daemon socket's group through an ACL.
Fixes https://gitea.com/gitea/runner/issues/1219
Fixes https://gitea.com/gitea/runner/issues/1193
Reviewed-on: https://gitea.com/gitea/runner/pulls/1226
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Implement speedups to job start and shutdown.
- Create the job container while services are still becoming healthy, and poll their health at a flat one second instead of a 2s to 32s doubling backoff
- Pull each service image once instead of twice, and fetch a warm action cache once instead of twice
- Report the job result before reclaiming its volumes, and reap volumes stranded by a runner that died mid-job
| Step | Scenario | Before | After |
| --- | --- | --- | --- |
| Complete job | Large workspace volume | 4.2s | 0.4s |
| Set up job | One service, 2s health interval | 7.08s | 3.26s |
| Set up job | Two cached actions from github.com | 1.81s | 1.34s |
| Set up job | Two cached actions from gitea.com | 2.42s | 1.95s |
| Set up job | Two actions, cold action cache | 6.62s | unchanged |
| Set up job | Minimal job, no services or actions | 0.62s | unchanged |
Assisted-by: Claude Code:Opus 5
Reviewed-on: https://gitea.com/gitea/runner/pulls/1218
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Fixes https://gitea.com/gitea/runner/issues/1213
Fix the DooD regression that mounts `/var/run/docker.sock` as a directory. Keep the Docker proxy available through job and post steps. Clean stale resources before opening it, then remove containers before their networks and volumes during teardown.
Use a unique filesystem probe and preserve socket ownership. Fall back to direct access when proxying is unsupported. Preserve exec output and clean up active streams and failed starts.
Add a real Docker job test for mounted socket access, post steps and resource cleanup.
---------
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1215
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
1. Dependency updates: gobwas/glob v1.0.0, which replaced its `Glob` interface with `*Pattern`, logrus v1.10.2, prometheus/client_model v0.6.3, golangci-lint v2.13.2, Go 1.27.1, docker/setup-qemu-action and nginx digests.
2. `make fmt-check` and `make tidy-check` report only what the formatter or `go mod tidy` would change, so unrelated uncommitted work no longer fails `make checks`.
---------
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1212
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
1. Add `GITEA_DOCKER_WORKSPACE` which holds the workspace path as the daemon sees it, enabling `${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data` in a compose file without having to resort to `bind_workdir` (which causes much more problems like breaking `actions/cache` because of unstable workspace paths).
2. Add container/network/volume cleanup for containers started within jobs, for example via `docker compose` inside a job. It works by running a lightweight docker socket proxy and injecting a `com.gitea.runner.job` label into every container creation and that label is used to remove containers started by that job at the end. Perf impact of this is near-zero.
Docs: https://gitea.com/gitea/docs/pulls/535
Assisted by Claude (Fable 5.1).
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1204
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
An action pinned to a full commit hash cloned the whole repository, and a cached one hit the network on every run. Now only the pinned commit is fetched at depth 1, a cached commit is reused with no network at all, and the action's `.git` directory no longer ships into the job container, matching GitHub.
```
repo cold ms warm ms cache KiB
actions/checkout 5807 → 787 1064 → 12 11492 → 2349
actions/setup-node 15257 → 1013 1365 → 21 64303 → 9924
actions/cache 18782 → 760 1589 → 16 60890 → 12507
actions/setup-go 5513 → 764 589 → 20 16600 → 9104
docker/login-action 25809 → 1225 1288 → 10 81709 → 12503
```
Reviewed-on: https://gitea.com/gitea/runner/pulls/1209
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Fixes 51 bugs discovered via comparison with `actions/runner`. Every fix has test coverage.
### Secrets
- A short secret registered no shifted-base64 form, so `base64("user:$TOKEN")` printed in the clear
- Encoded forms came only from the whole trimmed value, missing padded and per-line spellings
- Masks split only on `\n`, so `::add-mask::a%0Db` registered neither half
- Adds XML, expression-string and quote-trimming encoders
### Workflow commands
- Split at the last `::` or `]` rather than the first, so `::add-mask::a::b` registered no mask
- A command on the last line without a newline was ignored, and `::ADD-MASK::` did nothing
- `##[...]` did not decode `%3B`/`%5D`, properties lost anything after a second `=`
- `$GITHUB_ENV` and `::set-env::` now refuse `NODE_OPTIONS`
### Status
- `continue-on-error` reported failed, a cancelled job reported success, an `if:` error reported cancelled
- File commands ran after `continue-on-error`, failing the job while the step stayed green
- A bad job output aborted the whole run instead of that job
### Steps and actions
- `${{ matrix.* }}` and `${{ strategy.* }}` were empty inside composite actions
- Composite inputs leaked into nested actions as `INPUT_*`, `with:` matched case-sensitively, `pre` failures were dropped
- Docker actions dropped `runs.env` when the caller passed `with: args:`, and caller `args`/`entrypoint` beat the manifest
- An implicit shell ran with `pipefail`, a `shell:` without `{0}` passed without running
- `container.env` overrode job env and every `$GITHUB_ENV` write, heredocs lost leading blank lines, `$GITHUB_PATH` was not BOM-decoded
Written by Claude Opus 5.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1194
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Service containers were built without a volume policy, so every bind and mount they declared was dropped, whatever `valid_volumes` allowed. GitHub passes a service's declared volumes straight to `docker create`, so a workflow that mounts into a service silently did nothing here. Services now get the configured policy, but not `validVolumes()`, which would also hand them the docker daemon socket that GitHub mounts only into the job container.
### What changes for users
On the default `valid_volumes: []` nothing changes: a service's volumes are still dropped, now with a warning rather than in silence. Once `valid_volumes` is configured, a service's declared volumes are honored under it instead of discarded, which is what that setting already documents. No workflow that worked before stops working, and a service can reach no volume the policy does not already allow the job container, so this is not a breaking change.
---------
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1186
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
The runner's own `container.options` and the workflow's were joined into one string before parsing, so the host-escape filter added in https://gitea.com/gitea/runner/pulls/1058 dropped the administrator's options along with the workflow's. Setups that need `--device` or `--security-opt` from the config file had no way left to get them short of enabling privileged mode.
`NewContainerInput` now carries the two sources apart, as `RunnerOptions` and `WorkflowOptions`, down to the point where the filter runs. With privileged mode off, the host-escape fields are reset to what the runner's own options parse to on their own, so only the workflow's contribution is dropped.
Three further ways a workflow's options reached past its container, all resolved on the runner before anything reaches the daemon:
1. `--env-file` and `--label-file` name files that are read on the runner, so any file it could read became container environment or labels. Both are refused from a workflow now, and still serve the runner's own options.
2. A bare `--env NAME` was resolved from the runner's own environment by docker's validator. That lookup is gone, for every source. Use `runner.envs` or `runner.env_file` to pass a variable on.
3. A volume driver decides for itself what it mounts, and the local driver's `device=` option turns a name `valid_volumes` allows into a bind of any host path. A workflow's mounts may no longer carry one.
`--isolation`, `--volume-driver` and the two paths `--security-opt systempaths=unconfined` lands in were also missing from the fields a workflow may not set.
Last, the `--network and --net in the options will be ignored.` warning fired for every container, because the runner's own network mode is fed into the parsed options before the check runs.
Fixes https://gitea.com/gitea/runner/issues/1142
Reviewed-on: https://gitea.com/gitea/runner/pulls/1151
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Parallelize isolated workflow tests, consolidate redundant fixtures, and replace fixed waits with deterministic synchronization. Keep the readable curl service probe and use `getent` for hostname resolution.
Measured on the same machine with `make test`:
1. Wall time: 170.50s to 136.12s, down 34.38s or 20.2%.
1. `act/runner`: 138.417s to 124.291s, down 14.126s or 10.2%.
1. `act/runner` coverage: unchanged at 85.2%.
1. `TestDockerExecAbort`: 2.514s to 0.012s package time.
Stability checks:
1. Cancellation and deadline tests: 100 race-enabled repetitions.
1. Host runner suite: 10 race-enabled repetitions.
1. Changed Docker fixtures: 3 consecutive repetitions.
Full race suite, Go and Windows lint, source checks, and security scan pass.
Assisted-by: Codex:GPT-5
Reviewed-on: https://gitea.com/gitea/runner/pulls/1181
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Jobs occasionally go silent ([example](https://gitea.com/gitea/runner/actions/runs/805045/jobs/1055123)) mid-run and Gitea reaped them after `ZOMBIE_TASK_TIMEOUT`, with no error in the log. This contains a number of related fixes, all with full test coverage:
1. Bound every RPC to Gitea with a timeout, a stalled report otherwise parked logs and heartbeats for the whole job.
2. Cap `runner.fetch_timeout` at that ceiling.
3. Let only the daemon loop close its own channel, the race panicked the process.
4. Stop the job on any terminal server result, not just `RESULT_CANCELLED`.
5. Report that result instead of relabelling it as cancelled.
6. Log reporting failures once at each end of an outage instead of discarding them.
7. Clamp the acknowledged log index, a too-large ack panicked on a slice bound.
8. Stop reading server health from a `FetchTask` deadline, it marked the runner healthy and reset the error backoff on a timeout.
9. Return an error from the Docker version probe instead of a `logrus` panic.
10. Pass the context to go-git's fetch and pull.
11. Fail the clone when a refresh dies on a cancelled context.
12. Set `terminationGracePeriodSeconds` in the Kubernetes examples.
Also contains a deprecation fix for goreleaser.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1174
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Revert #1136 and use actionslib instead.
revert chore: bump the module path to `/v3`, take the version from the VCS stamp (#1136)
gitea can not consume the runner's api by version while it's version mismatches the module version:
```
go: gitea.com/gitea/runner@v3.0.1: invalid version: module contains a go.mod file,
so module path must match major version ("gitea.com/gitea/runner/v3")
```
Fix that by bumping the module version now. The existing `v3.0.0` and `v3.0.1` tags stay unusable, so a new tag is needed after this lands.
Also drop the version `-X` linker flags, which would otherwise have to repeat the new path in both `Makefile` and `.goreleaser.yaml`, where a stale path makes injection silently no-op. Go has recorded the module version in the build info since 1.24, so `Version()` reads it from there, keeping the variable as an override for builds without a VCS stamp.
That part started as https://gitea.com/gitea/runner/pulls/1137 but belongs here: the stamp resolves against the tags that are legal for the module path, so without the `/v3` bump it would report `v1.0.9-0.<ts>-<sha>`. Since `release-nightly.yml` triggers on every push to `main`, splitting them would publish a nightly with a `v1` version.
---------
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1136
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1148
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
gitea can not consume the runner's api by version while it's version mismatches the module version:
```
go: gitea.com/gitea/runner@v3.0.1: invalid version: module contains a go.mod file,
so module path must match major version ("gitea.com/gitea/runner/v3")
```
Fix that by bumping the module version now. The existing `v3.0.0` and `v3.0.1` tags stay unusable, so a new tag is needed after this lands.
Also drop the version `-X` linker flags, which would otherwise have to repeat the new path in both `Makefile` and `.goreleaser.yaml`, where a stale path makes injection silently no-op. Go has recorded the module version in the build info since 1.24, so `Version()` reads it from there, keeping the variable as an override for builds without a VCS stamp.
That part started as https://gitea.com/gitea/runner/pulls/1137 but belongs here: the stamp resolves against the tags that are legal for the module path, so without the `/v3` bump it would report `v1.0.9-0.<ts>-<sha>`. Since `release-nightly.yml` triggers on every push to `main`, splitting them would publish a nightly with a `v1` version.
---------
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1136
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Service containers were started and then left alone, so a job's first step could run while a database was still starting up. The runner now waits for every service whose image or `options` declare a healthcheck, as GitHub does. An unhealthy service fails the job with its container log, one that never becomes healthy fails it after `container.service_ready_timeout` (default `5m`, negative disables the wait), and one that exits without a healthcheck only gets its log and a warning.
The started containers also fill the `job` context, whose fields existed but were never populated: `job.container.{id,network}` and `job.services.<id>.{id,network,ports}`. `ports` is keyed by the plain container port, so `job.services.postgres.ports['5432']` resolves to the host port Docker picked.
---------
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1107
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
1. Drop `Masterminds/semver`, the Docker API version check needs dotted-numeric comparison and moby's client ships `versions.GreaterThanOrEqualTo`.
1. A malformed API version now reports as unsupported instead of panicking.
1. Drop `pkg/errors`, nothing used stack traces, `Wrap` or `Cause`.
1. Drop its depguard rule, and the `io/ioutil` one that only masked staticcheck's `SA1019`.
1. Re-sync `act/container/docker_cli.go` and `act/container/docker_cli_test.go`, copies of docker/cli's `opts.go` and `opts_test.go`, from a March and a 2022 commit to the version go.mod pins.
1. Record the local deviations in each header.
1. Fix `invalidParameter`, which lacked `Unwrap`, hiding the wrapped cause from `errors.Is` and `errors.As`.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1132
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Docker 29.7 extracts copied archives through `os.Root`, which refuses to follow a symlink to an absolute target, so copying into `/var/run/act` fails with `path escapes from parent` on the many images that link `/var/run` to `/run`. The daemon now resolves every path component for us before any tar entry name is built, and the destination is created with one directory entry per missing component, which no daemon version rejects.
Verified against real daemons (29.4.0, 29.5.0, 29.5.1, 29.5.3, 29.6.2, 29.7.0-rc.1) with `debian:bookworm` (absolute symlink) and `alpine:3` (relative symlink), and against `moby/go-archive` v0.2.0 through the pending fix branch.
1. Fixes https://gitea.com/gitea/runner/issues/1128
1. Upstream bug: https://github.com/moby/moby/issues/53258
1. Supersedes the no-op change in https://gitea.com/gitea/runner/pulls/1129, which cannot help since the daemon strips leading slashes itself
Reviewed-on: https://gitea.com/gitea/runner/pulls/1130
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
`CopyTarStream` creates the destination directory by extracting a one-entry tarball with `DestinationPath: "/"`, but named that entry with the absolute `destPath`. Docker Engine 29.5+ tightened path validation on the copy API and rejects absolute entry names against a `/` destination with `statat var/run/act/actions/<sha>: path escapes from parent`, so the action directory never reached the job container and `actions/checkout` failed during "Set up job". Stripping the leading slash makes the entry relative, matching what the sibling `copyDir` already does and the upstream fix in nektos/act v0.2.89. Adds a regression test asserting the mkdir tarball entry is relative.
Fixes#1128
Reviewed-on: https://gitea.com/gitea/runner/pulls/1129
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Workflow-controlled `jobs.<job>.container.options` were merged directly into the
Docker `HostConfig`. When the runner's privileged mode is disabled, only
`Privileged` was forced to `false` — host namespace flags, capability expansion,
security-profile overrides, and device/runtime access from the workflow YAML
survived into the final `HostConfig`. A workflow author could therefore enter
host PID/IPC namespaces and execute commands as root on the runner host:
```yaml
container:
image: ubuntu:22.04
options: >-
--pid=host --ipc=host --cap-add=ALL
--security-opt seccomp=unconfined --security-opt apparmor=unconfined
```
## Fix
`mergeContainerConfigs()` now strips the dangerous options-derived `HostConfig`
fields before merging when privileged mode is off: `PidMode`, `IpcMode`,
`UTSMode`, `CgroupnsMode`, `UsernsMode`, `CapAdd`, `SecurityOpt`, `Devices`,
`DeviceCgroupRules`, `DeviceRequests`, `VolumesFrom`, `Runtime`, `CgroupParent`,
and `Sysctls`. Each strip emits a warning, matching the existing
`--network ignored` handling. Options remain honored when privileged mode is
enabled, since the administrator has already opted into host access.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1058
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Leaked per-job networks each hold a subnet of the daemon's address pool and nothing reclaimed them, so a host eventually fails every job with `all predefined address pools have been fully subnetted`. This is what CI hit in https://gitea.com/gitea/runner/actions/runs/742177.
- teardown no longer loses a container, and its network with it: a failed `ContainerRemove` was reported as success, a container whose id was never learned was skipped, and the daemon's own `AutoRemove` teardown was raced
- the idle cleanup reclaims what teardown cannot: networks carry `com.gitea.runner.uuid`, so a runner only touches its own, and a cutoff keeps a job starting during the pass out of scope
- pull failures reported mid-stream were discarded, surfacing later as a confusing `No such image`; they now propagate, and fall back to a local copy instead of failing the job
- `NetworkCreate` retries pool exhaustion briefly, then says which knobs to turn
- digest-pinned images are not re-pulled, and removal kills first so it never waits out Podman's stop timeout (measured 10.1s → 0.09s per container)
---------
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1124
Reviewed-by: bircni <bircni@icloud.com>
Set `http_proxy`, `https_proxy` and `no_proxy` in the runner's environment and everything the runner controls uses them.
Go already read them for the runner's own requests. This adds jobs, in lower and upper case, service containers, and Dockerfile action builds.
Some hosts are added to `no_proxy` for jobs so they stay direct: the cache server, loopback, the job's service containers, and a `tcp://` Docker daemon. Without the last one the Docker client sends its API calls to the proxy and docker-in-docker breaks. Gitea is not added.
Images are pulled by the Docker daemon, which has its own proxy setting. In the `dind` images it reads these same variables. The runner warns at startup if it has a proxy and the daemon does not.
Fixes https://gitea.com/gitea/runner/issues/1118, originally reported as https://gitea.com/gitea/runner/issues/708.
---------
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1112
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
An unchecked type assertion panics on input it did not expect, as a missing `tool_cache` key did in https://gitea.com/gitea/runner/pulls/1122.
Every flagged site is now handled where it can fail, or typed so it cannot: a `lock.Keyed` replaces the two `sync.Map` mutex registries, and the reporter's outputs carry an explicit sent flag. Mocks keep their assertions, a mismatch there is a setup error the panic names.
Bugs it turned up (only the first is reachable from workflows):
1. A scalar `matrix.include` or `matrix.exclude`, e.g. `include: foo` or `include: [1, 2]`, panicked the runner with `interface conversion: interface {} is string, not map[string]interface {}`. Verified against `main`, it is now a workflow error. `OnSchedule` panicked the same way on a malformed `on.schedule` entry.
1. `ExternalURL()` panicked on the nil listener after `Close()`, the port is now resolved once at startup.
1. `errors.Is(err, git.ErrShortRef)` followed by `err.(*git.Error)` panics as soon as anything wraps that error, so it is `errors.As` now.
1. An output name the server acknowledged without ever being sent one was recorded as sent forever, which silently dropped a later value for that name.
48576ab3e5 fixes one discovered issue: a matrix key holding a nested object was logged and then run as if the job had no matrix, so it now fails like an unknown `exclude` key.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1123
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
1. Docker rejects two mounts on one target, so a `container.volumes:` or `--volume` aimed at `/opt/hostedtoolcache` failed the job with `Duplicate mount point`. Job and service volumes now displace the mount on the same path, and `name:/target:ro` no longer mounts read-write at the literal path `/target:ro`.
1. Setting `RUNNER_TOOL_CACHE` only changed what the variable said, the cache stayed where it was, so tools writing to it landed outside the mount and `${{ runner.tool_cache }}` disagreed with the variable. It now relocates the cache. Leaving it unset behaves as before.
1. Unknown `config.yaml` keys now warn instead of being dropped without a trace.
Fixes https://gitea.com/gitea/runner/issues/813
---------
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1122
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Adds `runner.hooks.job_started` and `runner.hooks.job_completed`: operator scripts that run inside the job environment, before the job's first step and after its last one.
```yaml
runner:
hooks:
job_started: /hooks/started.sh
job_completed: /hooks/completed.sh
```
Equivalent to GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`, which are read when unset: output is scanned for workflow commands, `$GITHUB_ENV` and `$GITHUB_PATH` are read back, and a non-zero exit fails the job.
Fixes: https://gitea.com/gitea/runner/issues/779
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1111
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Fixes https://gitea.com/gitea/runner/issues/667
Fixes https://gitea.com/gitea/runner/pulls/1103
`container.options` is parsed with docker/cli's `addFlags`, which omits the flags docker/cli registers on the `create` and `run` commands themselves. Those were rejected as unknown — most visibly `--platform`.
| Flag | Behavior | Why |
| --- | --- | --- |
| `--platform` | overrides the runner-wide container architecture | the only way to pick an image architecture per job, e.g. across a matrix |
| `--pull always\|missing\|never` | `always` forces a pull, `never` skips it | `force_pull` is runner-wide config today, with no per-job control |
Both are resolved in `NewContainer` because the image pull runs before the container is created and the two have to agree.
`--name`, `--quiet` and `--disable-content-trust` are now accepted and ignored, and `--use-api-socket` is rejected pointing at `container.docker_host`, so a valid `docker create` line no longer fails outright.
<sub>Written by Claude (Opus 4.8).</sub>
---------
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1104
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
## Problem
On host-mode Windows runners, a job can leave processes running after it finishes,
and those leftovers hold file handles that block deletion of the workspace.
Today a step's tree is only torn down when the step is *cancelled*
(`process.Killer`). A step that completes leaves whatever it spawned alive, and
the existing workspace scan in `terminateRunningProcesses` misses two shapes of
leftover: orphans whose parent already exited (no tree to walk, and their
executable often lives outside the workspace), and processes that merely *run in*
the workspace but reference no path from it — `Win32_Process` exposes no working
directory, so the scan cannot match them.
## Solution
Two additions in `internal/pkg/process`, both no-ops outside Windows:
- **`process.Group`** — a job-scoped Windows Job Object created with
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Step processes are assigned to it before
they get their own `Killer`, so the step's job nests inside the job's:
cancellation still kills exactly the step's tree, while `Remove` closing the
group makes the kernel terminate everything still assigned, whatever its
parentage. The kernel also drops the handle when the runner exits, so a crashed
runner cannot strand processes.
- **`process.KillProcessesWithCWDUnder`** — a best-effort net for processes that
never joined the job (started via a service or scheduled task). It reads each
process's working directory from its PEB and terminates those under a workspace
dir. Processes it cannot open are skipped.
---------
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1080
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Containers set `HostConfig.AutoRemove` but act also removes them explicitly, so the two removers race and the loser logs a 409 `removal of container X is already in progress` — seen at the end of nearly every `uses: docker://` step.
The explicit remove is redundant for `docker://` steps and docker actions (`Start(true)` already awaited exit), so it's skipped. Job and service containers keep both removers — their `sleep` entrypoint needs `AutoRemove` as a fallback reaper — so there the race is inherent and `remove()` now treats `NotFound` and `Conflict` as success.
---------
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1093
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
- Adds `runner.post_task_script` and `runner.post_task_script_timeout` (default `5m`) to run a host executable after each task’s built-in cleanup (post-steps, container teardown, bind-workdir removal).
- Stops task heartbeats via `Reporter.StopHeartbeats()` while the script runs so Gitea won’t assign overlapping work; the final task acknowledgement still happens in `reporter.Close()`.
- Script output goes to the runner process log; non-zero exits are warned only and do not change the job result.
- Documents lifecycle, offline behavior, timeouts, and Windows limits (`.ps1` not supported yet) in `docs/post-task-script.md`.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1026
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Cancelling a job on a Linux/macOS host runner can leave the spawned process
tree running and hang the runner — the same failure mode fixed for Windows in
#1011, just on the other platforms.
Steps are launched as process-group leaders (`Setpgid`, or `Setsid` for the PTY
path), but the default `exec.CommandContext` cancellation only kills the
**direct child**. When a step launches a shell that starts a child which in turn
spawns further background processes, cancelling the job leaves the descendants
running. Because those orphans inherited the step's stdout/stderr pipe, the read
end never hits EOF and `cmd.Wait()` blocks forever.
Because the step executor never returns:
- the orphaned processes keep running (the cancelled work is not actually
stopped), and
- end-of-job cleanup is never reached, so the runner appears to go offline / stop
picking up jobs.
## Fix
Apply the same tree-kill approach as Windows, using the Unix counterpart of a Job
Object: the **process group**.
- Add a Unix `processKiller` (`process_unix.go`) that captures the step's PGID
(== PID, since the step is launched as a group leader) and sends `SIGKILL` to
the whole group on cancellation. This also closes the inherited pipe handles so
`cmd.Wait()` can return. `ESRCH` (group already gone) is not treated as an error.
- Restrict the previous no-op stub (`process_other.go`) to `plan9` and have it
fall back to a single-process kill, preserving plan9's prior behaviour.
- Wire `cmd.Cancel` (tree kill) and `cmd.WaitDelay` (10s) **unconditionally** in
`exec()` instead of Windows-only. `WaitDelay` also covers a step that
backgrounds a process holding the pipe open after the main process exits.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1025
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
## Problem
Several runner code paths could drop the **tail** of a step's log output, so a
failing (or cancelled) step would show output that is missing its last line(s).
This was observed in practice and traced to four independent issues.
## Root causes & fixes
### 1. Trailing line without a newline was never flushed
`common.lineWriter` buffers output until it sees a `\n`. A final line **without**
a trailing newline (e.g. an error message printed right before a process exits,
a panic, `printf` without `\n`) stayed in the internal buffer and was never
emitted — the writer exposed no flush at all.
- Added `lineWriter.Flush()` (idempotent), a `Flusher` interface, and a
`FlushWriter(io.Writer)` helper.
- Flush at every stream EOF: the exec copy goroutine, the container `attach()`
streaming goroutine, and at step end (`useStepLogger`).
### 2. Cancellation/timeout truncated output
`waitForCommand` returned immediately on `ctx.Done()` and abandoned the
output-copy goroutine, losing output the command had already produced. It now
drains with a bounded grace period before returning. The response channel is
buffered so the goroutine can't leak if the drain times out.
### 3. `attach()` raced the final bytes
Container output was streamed in a fire-and-forget goroutine that `wait()` did
not synchronize with, so the step could proceed before the last bytes were
written. `wait()` now blocks on the streaming goroutine (bounded) so output is
fully drained and flushed first.
### 4. `::stop-commands::` silently dropped lines from the step log
Lines between `::stop-commands::<token>` and its end token were echoed without
the `raw_output` field **and** short-circuited the handler chain (`return false`),
so they never reached the step log (non-raw entries aren't appended while a step
is running). Now returns `true` so they are still captured.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1028
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Fixes#1023.
## Problem
In Windows host mode, a single stalled delete syscall (AV/EDR filter driver, unresponsive mount, dying disk) wedged the job forever at `Cleaning up container`. `HostEnvironment.Remove()` bounds every teardown phase (`terminateRunningProcesses`, both `removePathWithRetry` calls) except the `CleanUp` callback — an unbounded `os.RemoveAll(miscpath)` assigned in `startHostEnvironment`. The runner then held its capacity slot indefinitely, the task was reaped as a zombie, and there were no diagnostics.
## Fix
- **Bound the cleanup (availability):** `Remove()` now runs `CleanUp` under `hostCleanupTimeout` (30s) via `runWithTimeout`; on timeout it logs a warning and continues job completion. The stuck goroutine is left to finish (a delete syscall can't be interrupted). Added debug logs around the phase.
- **Reclaim the leak (disk hygiene):** a timed-out cleanup can leave a scratch dir behind, so the existing idle stale-dir sweep is extended to also remove orphaned host-mode scratch dirs (16-hex names) under `Host.WorkdirParent`, leaving the shared `tool_cache` and operator data untouched. The `bind_workdir` gate is dropped from `shouldRunIdleCleanup` so host-mode runners run the sweep.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1024
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>