Compare commits

...
209 Commits
Author SHA1 Message Date
silverwind b5305c6785 docs: add a launchd example for macOS (#1408)
Adds `examples/launchd` with a plist and the steps to run the runner on macOS, next to the existing systemd example.

The runner runs in the login session of the user that jobs should run as, which gives jobs the login keychain and the GUI. The README also covers the two problems that setup runs into: macOS blocks the runner from hosts on the Mac's own subnet, and Git's keychain helper can stop a job at a dialog.

_Written by Claude (Fable 5.2)._

Reviewed-on: https://gitea.com/gitea/runner/pulls/1408
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-10 17:17:35 +00:00
bircni a021d3139d enhance: make shared tool cache safe at any capacity (#1401)
Co-authored-by: bircni <[email protected]>
2026-11-10 14:25:50 +00:00
bircniandsilverwind 84c0fcd1fa fix!: remove the dbfs log workaround and require Gitea 1.26.2 (#1395)
Gitea 1.26.2 finalizes logs on an empty `UpdateLog` with `NoMore` (https://github.com/go-gitea/gitea/pull/37687), so the sentinel row is no longer needed. On older servers, the final log of most jobs would never be archived and stays in `dbfs_data`, so this raises the minimum Gitea version. It lives in `ver.MinGiteaVersion`, which the error for servers without `Declare` now names.

Fixes: https://gitea.com/gitea/runner/issues/953
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1395
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-10 11:35:16 +00:00
bircniandsilverwind c59caa9689 refactor!: remove the builtin actions (#1407)
Removes `builtin:checkout`, `builtin:upload-artifact` and `builtin:download-artifact`, along with the container helpers only they used.

Builtin actions are tied to the runner version, so users cannot pin them, and they diverge from the shareable actions ecosystem that tools like zizmor build on. Workflows should use `actions/checkout`, `actions/upload-artifact` and `actions/download-artifact`, or lightweight third-party actions such as https://github.com/taiki-e/checkout-action.

**Breaking:** steps with `uses: builtin:checkout`, released in v4.0.0, now fail and need to switch to one of the actions above. The artifact actions were never released.

Docs: https://gitea.com/gitea/docs/pulls/643
Fixes: https://gitea.com/gitea/runner/issues/1405
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1407
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-10 11:05:38 +00:00
Renovate Bot 85d007c47f chore(deps): update dependencies (#1406)
This PR contains the following updates:

| Package | Type | Update | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|---|
| docker.gitea.com/runner-images |  | digest | `657378e` → `b96ba46` |  |  |
| [github.com/docker/cli](https://github.com/docker/cli) | require | patch | `v29.9.2+incompatible` → `v29.9.3+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.9.3+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.9.2+incompatible/v29.9.3+incompatible?slim=true) |
| [rancher/k3s](https://github.com/k3s-io/k3s) |  | patch | `v1.37.1-k3s1` → `v1.37.2-k3s1` | ![age](https://developer.mend.io/api/mc/badges/age/docker/rancher%2fk3s/v1.37.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/rancher%2fk3s/v1.37.1/v1.37.2?slim=true) |

---

### Release Notes

<details>
<summary>docker/cli (github.com/docker/cli)</summary>

### [`v29.9.3+incompatible`](https://github.com/docker/cli/compare/v29.9.2...v29.9.3)

[Compare Source](https://github.com/docker/cli/compare/v29.9.2...v29.9.3)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xOTkuMiIsInVwZGF0ZWRJblZlciI6IjQ0LjE5OS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/1406
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-11-09 04:31:50 +00:00
silverwindandbircni d34e4cf11a fix: stop a stalled docker daemon from hanging jobs and the e2e suite (#1404)
When the docker daemon stopped answering on https://gitea.com/gitea/runner/actions/runs/1204971, unbounded exec runs hung the e2e suite until the 20m `go test` panic, which skipped teardown. Exec runs and teardown are now bounded, and attached exec streams, including the runner's user ID probe, close with their context.

CLI daemons now stop with SIGTERM, so job volumes no longer leak.

_Written by Claude (Opus 5.5)._

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1404
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-07 08:07:41 +00:00
8e999cd039 fix: re-detect the cache address per job and prefer IPv4 (#1398)
The cache address was detected once at startup, and the interface fallback ranked ethernet names above IPv4. A runner that started before DHCPv4 finished could advertise a global IPv6 address to IPv4-only job networks until it restarted, failing every artifact and cache call.

IPv4 now ranks first, and the address is detected again for each job unless `cache.host` sets it. Detecting an IPv6 address logs a warning.

Fixes: https://gitea.com/gitea/runner/issues/1379
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1398
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-07 06:41:25 +00:00
silverwind cd748053b0 docs: trim comments and docs from workspace emptying change (#1403)
Follow-up to https://gitea.com/gitea/runner/commit/79faf5e55df7a3b312fb4888b7405a58e01f567c. Reverts the post-task-script doc change, which is too much detail for that page. Also shortens the `DrainGracePeriod` comment and the other comments that commit added, since the code and log messages already say most of it.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1403
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-07 06:14:41 +00:00
bircni 9bb72bbe5c enhance: add ubuntu-26.04 to the default labels (#1396)
Newly registered runners get an `ubuntu-26.04` label alongside the existing defaults.

Ref: https://gitea.com/gitea/runner/issues/1334
Reviewed-on: https://gitea.com/gitea/runner/pulls/1396
Reviewed-by: Lunny Xiao <[email protected]>
2026-11-07 05:12:04 +00:00
79faf5e55d fix: empty root-owned bind workspaces for non-root runners (#1399)
A job running as root leaves root-owned files in the bind-mounted workspace. A non-root runner on Linux cannot remove them, so workspaces pile up with only a warning. Such a runner now empties the workspace as root from inside the job container, right before removing the container. Root runners and Docker Desktop already remove these files themselves and skip this.

The workspace is only emptied when it is exactly the runner's own mountpoint with no mount below it. Otherwise, or when the command fails, the existing removal and warning apply. Jobs of a reusable workflow share the workspace, so they are not covered and still end in the existing warning.

Fixes: https://gitea.com/gitea/runner/issues/879
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1399
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-07 04:33:19 +00:00
bircni 993389d428 docs: trust a private registry CA in the dind images (#1397)
The bundled Docker daemon pulls job images, so it needs to trust a private registry's CA itself, which `runner.ca_file` doesn't cover. Documents the `certs.d` mount for both dind images.

Ref: https://gitea.com/gitea/runner/issues/1390
Reviewed-on: https://gitea.com/gitea/runner/pulls/1397
Reviewed-by: Lunny Xiao <[email protected]>
2026-11-06 21:36:06 +00:00
silverwind db76fd5159 enhance: improve hashFiles coercion and log grouping (#1394)
- Fix `hashFiles()` argument conversion for numbers, booleans, and null to match GitHub expression semantics.
- Fix rendering of mixed-case, indented, and prefixed legacy group commands while preserving recognized prefixes and escaping.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1394
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-06 20:03:39 +00:00
bircni c8b16f4792 enhance: add builtin:download-artifact (#1344)
Co-authored-by: bircni <[email protected]>
2026-11-06 03:32:59 +00:00
bircni 02446620df enhance: add builtin:upload-artifact (#1343)
Co-authored-by: bircni <[email protected]>
2026-11-06 03:17:49 +00:00
8c8950d9ef refactor(container): stream copies into the job and archives out of the host (#1340)
Adds `CopyTarStream` to the job environments. It extracts directories and files into the job as they are written, kept below the destination and owned by the job user along with any directories it creates, so built-in actions can put large content in place without buffering it. The host's `GetContainerArchive` now streams instead of building the archive in memory.

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1340
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-06 02:22:40 +00:00
bircniandsilverwind b88d111cc5 feat: port the path patterns of @actions/glob (#1342)
Built-in actions that select files need to match what the JavaScript actions match. This ports @actions/glob 0.6.1 and the minimatch 3 subset it uses, as bundled in `actions/upload-artifact` v7. It only answers questions about paths; walking the files stays with the caller. Only malformed bracket expressions and some non-ASCII case folding on Windows can match differently.

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1342
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-06 01:13:07 +00:00
silverwind bb6d793cb9 fix: make container.sweep opt-in (#1389)
Sweeping removed every container a job started through its Docker proxy, which silently tore down services that jobs deploy to the runner's own Docker daemon. Deploying from a runner on the target host is common, so `container.sweep` now defaults to `false` and CI-only runners can opt in with `container.sweep: true`.

Fixes https://gitea.com/gitea/runner/issues/1385

Reviewed-on: https://gitea.com/gitea/runner/pulls/1389
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-05 13:50:46 +00:00
silverwind 95ef17d022 fix: prevent masking gaps in workflow command logs (#1384)
Mask partially escaped workflow command output and complete one-character values while preserving literal output and stream line endings.

Fixes: https://gitea.com/gitea/runner/issues/300
Reviewed-on: https://gitea.com/gitea/runner/pulls/1384
Reviewed-by: bircni <[email protected]>
2026-11-05 08:01:44 +00:00
Lunny Xiaoandsilverwind ee03687688 fix: upgrade Alpine packages in the runner images (#1388)
All images run `apk upgrade` before installing packages, so they pick up Alpine fixes their base images lag on. `docker:29.9.2-dind` shipped libexpat 2.9.0-r0 while Alpine 3.24 provides 2.10.0-r0 with the security fixes, and both `docker:29.9.3-dind` and `alpine:3.24` still ship apk-tools 3.0.8-r0 where 3.0.9-r0 is available. The dind bases are also bumped to `docker:29.9.3`, which drops several vulnerable bundled binaries' findings.

Verified by building the targets locally: libexpat is 2.10.0-r0 (was 2.9.0-r0), apk-tools is 3.0.9-r0 (was 3.0.8-r0), and Trivy reports no Alpine package findings on the dind images.

Assisted-by: Codet:claude
Assisted-by: Claude Code:claude-opus-5-5
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1388
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
2026-11-05 07:45:27 +00:00
silverwind 30e600d8f6 test: speed up (#1386)
Reduce redundant fixture setup, Git subprocesses, and large data copies while preserving test coverage.

Local macOS measurements against main:

| Measurement | Before | After |
| --- | ---: | ---: |
| Git package time, median of 3 alternating runs | 4.09 s | 3.48 s (-15%) |
| Allocations across 18 action-bundle cases | 489 MiB | 251 MiB (-49%) |
| Traced Git starts per checkout table run | 234 | 175 |

Reviewed-on: https://gitea.com/gitea/runner/pulls/1386
Reviewed-by: bircni <[email protected]>
2026-11-05 07:33:17 +00:00
silverwind 664978c819 test: fix windows path test (#1387)
`TestCopyWorkflowFilesHandsNodeTheRunnerCAUnlessTheJobOrImageSetsIt` from https://gitea.com/gitea/runner/pulls/1366 fails on Windows. `HostEnvironment.GetActPath` turns backslashes into slashes there, so `NODE_EXTRA_CA_CERTS` gets the slash form while the test expects the raw temp dir path. The runner behaviour is correct, only the expectation is wrong.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1387
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-05 07:14:56 +00:00
aa56f46c89 enhance: trust a private CA with runner.ca_file and add container.volumes (#1366)
Adds `container.volumes`, mounted into every Docker job, service and action container. Sources are paths on the Docker daemon's host and need no `valid_volumes` entry, and workflow volumes may use them only with the same access.

Adds `runner.ca_file`, a private CA trusted for the Gitea instance besides the system's by the runner's connections and its artifact proxy. Runner-side git and `builtin:checkout` use it as their CA bundle for the instance, persisted with the checkout's credentials. Jobs get it at `/var/run/act/ca.crt` with `NODE_EXTRA_CA_CERTS` pointing there unless the job or its image sets it. Git in jobs, as run by `actions/checkout`, needs the CA in the image's bundle, for example through `container.volumes`.

Fixes: https://gitea.com/gitea/runner/issues/568
Fixes: https://gitea.com/gitea/runner/issues/671
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1366
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-05 05:28:40 +00:00
silverwindandLunny Xiao 7a9fccb842 ci: sign release checksums with the imported GPG key (#1382)
The release job has imported the Teabot GPG key since https://gitea.com/gitea/runner/pulls/116, but `.goreleaser.yaml` never had a `signs:` block, so releases were never signed. Add it so `checksums.txt` gets a detached `checksums.txt.sig`, the same way https://gitea.com/gitea/cli signs its releases.

The nightly job imports the key as well since the `signs:` block applies to nightly builds, and the R2 publisher mirrors the signature to dl.gitea.com.

_Written by Claude (Opus 5.5)._

---------

Co-authored-by: Lunny Xiao <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1382
Reviewed-by: Lunny Xiao <[email protected]>
2026-11-05 04:28:22 +00:00
silverwindandLunny Xiao 7d6e1ff021 test: fix TestRunExecListUsesJobEventAndAllPlans hang on macOS (#1378)
`TestRunExecListUsesJobEventAndAllPlans` hangs until the test timeout on macOS.

Regression from https://gitea.com/gitea/runner/pulls/1338, which added a fifth `runExecList` call to the test to cover `exec --list --detect-event` without events. That took the captured output from 498 to 560 bytes. `captureStdout` swapped `os.Stdout` for a pipe it only read after the callback returned, and on macOS 27.2 an unread pipe holds 512 bytes, so the write blocked forever. Linux pipes hold 64 KiB, so was unaffected.

`printList` and `runExecList` now write to a passed writer, `exec --list` hands them `cmd.OutOrStdout()`, and the tests pass a buffer, so no test swaps `os.Stdout` anymore.

---------

Co-authored-by: Lunny Xiao <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1378
Reviewed-by: Lunny Xiao <[email protected]>
2026-11-05 04:22:01 +00:00
silverwind 319567213d docs: use latest image tags in examples (#1383)
The README's Docker example and the Docker and Kubernetes examples pointed at `nightly` images, while the README recommends `latest-dind`. Deployments copied from them should run the latest release, not a build of `main`.

None of the examples changed since v5.3.0, so they work as written with the current `latest` tags.

_Written by Claude (Opus 5.5)._

Reviewed-on: https://gitea.com/gitea/runner/pulls/1383
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-04 23:00:45 +00:00
silverwind 0093731295 chore(deps): update go.yaml.in/yaml/v4 to v4.0.0 (#1381)
Update go-yaml from the rc.6 pseudo-version to the stable v4.0.0 release, and actionslib to v1.5.2, which does the same in https://gitea.com/gitea/actionslib/pulls/32.

v4 accepts `:` as a mapping with an empty key, so the reusable workflow tests now use an unclosed `[` as their invalid workflow. Gitea counterpart: https://github.com/go-gitea/gitea/pull/40222.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1381
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-04 20:05:17 +00:00
silverwind 3e1b4b42c2 fix(cache): stop the cache server from running its volume out of space (#1377)
Committing a cache upload copied every part into a new archive before deleting the parts, so a 5 GB save needed 10 GB free. Failed writes and assemblies left their partial bytes until the next stale sweep, up to an hour later, and every other save on the runner failed meanwhile. Nothing made room for an upload, so once the cache had filled its volume down to the free space floor, larger saves kept failing.

- Assemble in place, deleting each part once appended.
- Drop partial parts and archives on failure.
- Before a part would cross the floor, evict the least recently used entries.
- Reject uploads to reservations the sweep already deleted, which left files nothing removed.

_Written by Claude (Opus 5.5)._

Reviewed-on: https://gitea.com/gitea/runner/pulls/1377
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-04 16:38:21 +00:00
silverwind dcba546cb3 fix: align process handling and fix Windows crashes (#1376)
The intermittent Windows test crashes came from step errors formatting a nil `rc.Run`. `fmt` recovers the nil dereference, but on CPUs with AMX the recovered fault overflows into the heap (https://github.com/golang/go/issues/81238). The errors now name the job, and the Windows test VM hides AMX and runs on UTC, so cached boots no longer jump back 8 hours mid-test.

Windows commands now start suspended and resume once they are in their job objects, so no child escapes them. That drops the process scan reading other processes' memory and the last `unsafe` code, which `depguard` now denies. Step processes outlive a crashed runner, and commands are no longer looked up in the runner's working directory, both as with GitHub's runner.

Like GitHub's runner, host jobs on Linux and macOS now end the processes carrying the job's `RUNNER_TRACKING_ID`, and image pulls and Dockerfile action builds are retried.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1376
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-04 16:32:22 +00:00
silverwind 142d9f8c88 fix: give each Dockerfile action image its own ID (#1372)
Identical Dockerfile builds in concurrent jobs share one image ID under their per-job tags. When a job ends and removes its tag, the daemon deletes the image while another job's build is about to tag it, failing that job with `No such image`. This also hits `TestRunEvent` in CI, where two parallel subtests build the same action.

Label each build with its tag, so every job gets its own image on top of the shared cached layers and removing it never touches another job's.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1372
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-04 07:08:45 +00:00
silverwindandbircni 54b2cb944c fix: give git-lfs the checkout token once (#1371)
`builtin:checkout` set the token both in its persisted credentials file and in the step's environment. git-lfs before 3.8.0 then sent `Authorization` twice on the LFS batch request, which nginx rejects with 400.

Write the credentials file once the checkout succeeded, since it only serves later steps. A failed checkout no longer persists credentials.

Users can also upgrade git-lfs to fix the root bug, but this fix is worth to keep regardless.

Fixes: https://gitea.com/gitea/runner/issues/1370
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1371
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-04 06:42:00 +00:00
Zettat123andsilverwind b65fd0f8f8 ci: run the Windows tests on a runner with KVM (#1369)
Run the Windows tests on `ubuntu-latest-kvm` as a PR job, with the installed Windows cached, minutes instead of ~1h40m. Aligned with https://gitea.com/gitea/gitea-mcp/pulls/321 and https://gitea.com/gitea/cli/pulls/79.

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1369
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-11-04 02:08:38 +00:00
silverwind f2fbd67962 enhance: support Docker on FreeBSD and restructure the README (#1367)
1. Restructures the README into installation, quickstart, deployment, configuration, jobs and operation, and shortens it to user-facing prose. Option details are left to `config.example.yaml` and `docs/`, existing heading anchors are kept where possible.
2. FreeBSD builds used the Docker stub, so `docker://` labels, services and Docker actions failed there. The Docker code works on FreeBSD against a remote daemon, so it is enabled, and host jobs report `runner.os` as `FreeBSD`.
3. `make build` on Windows now writes `gitea-runner.exe`, taking the suffix from `go env GOEXE`.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1367
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-03 02:42:25 +00:00
silverwind d6f5b68a0d enhance: test on Windows and fix what it found (#1348)
Adds a weekly and on-demand workflow that runs the cross-compiled tests on Windows Server 2025 Core in a [dockur/windows](https://github.com/dockur/windows) QEMU VM, and fixes the Windows bugs it found in host and UNC paths, env handling, shell quoting, post-task scripts, health checks, checkout, action symlinks, step summaries, Git client certificates and docker mode from a Windows host.

Host-mode steps now emit UTF-8, `bash` falls back to Git's bash instead of the WSL launcher, the runner can run as a native Windows service, steps can keep background helpers with `RUNNER_TRACKING_ID`, and Windows arm64 binaries are released.

Fixes: https://gitea.com/gitea/runner/issues/1339
Fixes: https://gitea.com/gitea/runner/issues/637
Fixes: https://gitea.com/gitea/runner/issues/1172
Fixes: https://gitea.com/gitea/runner/issues/347
Reviewed-on: https://gitea.com/gitea/runner/pulls/1348
Reviewed-by: bircni <[email protected]>
2026-11-03 01:26:10 +00:00
Renovate Bot 6e42621b4a chore(deps): update dependencies (#1365)
Co-authored-by: Renovate Bot <[email protected]>
2026-11-02 08:16:42 +00:00
adamaruandsilverwind 228effab64 fix(dind): double-fork in s6-notifyoncheck to eliminate zombie processes (#1364)
In `scripts/s6/docker/run`, `s6-notifyoncheck` runs without the `-d` flag:

```bash
exec s6-notifyoncheck -n 0 dockerd-entrypoint.sh
```

Without `-d`, `s6-notifyoncheck` performs a single fork to execute the background checking process. The parent immediately replaces itself via `exec` with `dockerd-entrypoint.sh` (which calls `exec rootlesskit` on `dind-rootless`).

Because only a single fork occurred, the background checking child remains parented to `rootlesskit`. When the check passes and the process terminates:
1. `rootlesskit` did not spawn this process and never reaps it via `waitpid()`.
2. Under standard Linux process semantics, `s6-svscan` (PID 1) cannot adopt or reap the child because its parent (`rootlesskit`) is still running.
3. The terminated process remains as a permanent zombie (`[s6-notifyoncheck] <defunct>`) for the container's entire lifespan.

Adding `-d` instructs `s6-notifyoncheck` to double-fork, detaching the background check so it is immediately adopted by `s6-svscan` (PID 1) and cleanly reaped upon exit.

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1364
Reviewed-by: silverwind <[email protected]>
Co-authored-by: adamaru <[email protected]>
2026-11-01 07:20:20 +00:00
silverwindandbircni a46d3f3eda test(e2e): speed up and cover exec and Kubernetes modes (#1363)
Follow-up to https://gitea.com/gitea/runner/pulls/1362, whose crash e2e missed because it only drove the daemon on Docker.

- `dispatch`, `failure_paths` and `reusable_workflow` run through the daemon and `exec`, each on Docker and one shared k3s cluster.
- Fixed sleeps become gates the test releases, the job timeout uses a 15s runner deadline, k3s boots alongside Gitea, Gitea's data is on tmpfs.
- Outside a container, `make test-e2e` runs `go test` in a Linux container on its own network like CI, so local runs take the same paths.

Local wall time:

| | Before | After |
|---|---|---|
| Suite | 84s | 27–35s |
| `job_timeout` | 64s | 17s |
| `reconnect` | 22s | 4.5s |
| `capacity/serial` | 20s | 2s |
| `cli_shutdown_drains` | 14s | 2.3s |
| `kubernetes` | 31s | 21–23s |

_Written by Claude (Opus 5.5)._

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1363
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-31 10:47:35 +00:00
2e72cf902a enhance(kubernetes): fetch job pod copies from the runner over the pod network (#1354)
The Kubernetes backend copies actions and workspaces into job containers with `tar` over `pods/exec`, so every byte goes through the API server. On managed clusters that often sits in other zones than the nodes, so each byte can cross zones twice, and the copies load the API server.

With `kubernetes.transfer.mode: auto` or `direct`, an init container copies `gitea-runner` into the job pod, and the job container fetches directories and copies of 64 KiB or more gzipped from the runner over the pod network. `auto` falls back to the API server for a job pod that cannot reach the runner, `direct` fails the job.

Copies can hold secrets, so each is served once, over TLS 1.3 pinned to a certificate the runner generates, only to the job pod's IPs.

Copying setup-go@v5, cache@v4, setup-java@v4 and build-push-action@v6 sends 11.6 MB through the API server on main and 532 B with this PR.

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1354
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
2026-10-31 08:00:36 +00:00
silverwind f4741df90d fix(exec): load local reusable workflows from disk with --no-skip-checkout (#1362)
`gitea-runner exec --no-skip-checkout` panics with a nil pointer dereference on any job that calls a local reusable workflow (`uses: ./.gitea/workflows/x.yml`).

Fetching a local reusable workflow from the instance was gated on `NoSkipCheckout`, but it reads the repository and SHA from the task's preset context, which only the daemon has. `exec` always has the workflow on disk, so the fetch is now gated on the preset instead, and `--no-skip-checkout` only governs checkout again. Daemon behavior is unchanged.

The `exec` e2e test now also runs the existing reusable caller/callee pair with `--no-skip-checkout`.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1362
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-31 05:24:36 +00:00
261ab374ce enhance(cache): scope cache entries by ref like GitHub (#1361)
Cache entries were only scoped by repository, so an entry a pull request saved could be restored on the default branch, for example through a `restore-keys` prefix.

Entries now carry the ref of the job that saved them. As on GitHub ([Restrictions for accessing a cache](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache)), a job restores entries of its own ref first, then of its pull request's base branch, then of the default branch. Pull request events are scoped by their pull request number.

Entries saved before this change are no longer restored and expire through `cache.retention`, so caches start cold once after the upgrade. Runners sharing a cache, and an external `cache-server`, should be upgraded together.

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1361
Reviewed-by: silverwind <[email protected]>
Co-authored-by: JorgeDLMV <[email protected]>
2026-10-30 22:34:45 +00:00
KenanZhuandsilverwind ca8e9c3c64 enhance: add GITEA_* aliases for built-in workflow environment variables (#1358)
Closes https://gitea.com/gitea/runner/issues/1317, which makes https://github.com/go-gitea/gitea/issues/39813 redundant as the runner sets these variables.

Every built-in `GITHUB_*` environment variable the runner sets is now also available as `GITEA_*` with the same value, so Gitea-native workflows don't have to rely on GitHub naming. The `GITEA_*` names mirror the `GITHUB_*` ones after their last write, so they stay equal and are unset together.

Workflow, job and runner `envs` values for these `GITEA_*` names are now overwritten, the same as for `GITHUB_*`.

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1358
Reviewed-by: silverwind <[email protected]>
Co-authored-by: KenanZhu <[email protected]>
2026-10-30 21:21:02 +00:00
silverwindandbircni d8d4a4657d fix: scope the action identity to the step executing an action (#1359)
`github.action_repository`/`github.action_ref` and their env vars now belong to the step executing an action, like on GitHub: set for remote actions, empty for `run`, `docker://` and local steps. Before, a local action nested in a remote composite resolved `$/` against the remote action instead of the workflow's repository.

Updates actionslib to v1.5.1, which accepts `$/` as the repository root action.

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1359
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-29 17:41:27 +00:00
silverwind 5fd2f82735 test: fix race in TestTreeKillCancelBeforeCaptureKillsTree (#1360)
`TestTreeKillCancelBeforeCaptureKillsTree` fails intermittently on CI, e.g. https://gitea.com/gitea/runner/actions/runs/1155856.

Dash, the Ubuntu `/bin/sh`, points a background job's stdin at `/dev/null` before applying its redirections, so `cat <&0 &` read EOF and exited at once and the liveness check raced it. The pipe is now handed over on fd 3, which dash leaves alone.

Introduced in https://gitea.com/gitea/runner/pulls/1338

Reviewed-on: https://gitea.com/gitea/runner/pulls/1360
Reviewed-by: Zettat123 <[email protected]>
2026-10-29 16:46:53 +00:00
silverwind c6567296a0 fix: stop the docker proxy probe from leaking its container (#1357)
When the probe's deadline expired during `ContainerCreate`, dockerd still created the container but the probe never got its ID, leaving it in `Created` with its image's anonymous volumes. A slow daemon leaked one per job.

The probe now runs detached with its own timeout, so the create is never cancelled and the container is always removed. Callers still fall back after their own deadline, and the runner waits for pending probes before exiting.

Fixes: https://gitea.com/gitea/runner/issues/1353
Reviewed-on: https://gitea.com/gitea/runner/pulls/1357
Reviewed-by: Zettat123 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-29 15:53:32 +00:00
Renovate Bot e3607b576e fix(deps): update module golang.org/x/net to v0.62.0 [security] (#1356)
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/net](https://pkg.go.dev/golang.org/x/net) | [`v0.61.0` → `v0.62.0`](https://cs.opensource.google/go/x/net/+/refs/tags/v0.61.0...refs/tags/v0.62.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fnet/v0.62.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fnet/v0.61.0/v0.62.0?slim=true) |

---

### Avoid quadratic complexity when coalescing text nodes in html in golang.org/x/net
[CVE-2026-78661](https://nvd.nist.gov/vuln/detail/CVE-2026-78661) / [GO-2026-6860](https://pkg.go.dev/vuln/GO-2026-6860)

<details>
<summary>More information</summary>

#### Details
When coalescing adjacent text nodes in addText and fosterParent, appending to an existing TextNode's Data via string concatenation (n.Data += text) reallocated and copied the accumulated string on every token. Inputs containing many short text runs separated by ignored end tags (such as "a</x>") caused O(n^2) allocations and byte copies.

Accumulate coalesced text in per-node byte slices during parsing and materialize Data strings once at the end of parse.

#### Severity
Unknown

#### References
- [https://go.dev/cl/849365](https://go.dev/cl/849365)
- [https://go.dev/issue/82224](https://go.dev/issue/82224)
- [https://groups.google.com/g/golang-announce/c/sqrs2vTx_Xs](https://groups.google.com/g/golang-announce/c/sqrs2vTx_Xs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6860) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Check all ancestor namespace transitions when rendering raw text in html in golang.org/x/net
[CVE-2026-97045](https://nvd.nist.gov/vuln/detail/CVE-2026-97045) / [GO-2026-6866](https://pkg.go.dev/vuln/GO-2026-6866)

<details>
<summary>More information</summary>

#### Details
In childTextNodesAreLiteral, walking up the tree previously stopped at the nearest foreign ancestor once an HTML integration point was found. However, that integration point may itself be a descendant of an element that was foster-parented into a foreign namespace (such as <mglyph> or <malignmark> fostered under <mtext>), causing a parser to resolve the subtree in a different foreign namespace upon re-parsing the rendered output.

Continue walking the full ancestor chain and verify that every transition from a non-empty namespace occurs at an HTML integration point before emitting literal text.

#### Severity
Unknown

#### References
- [https://go.dev/cl/855121](https://go.dev/cl/855121)
- [https://go.dev/issue/82622](https://go.dev/issue/82622)
- [https://groups.google.com/g/golang-announce/c/sqrs2vTx_Xs](https://groups.google.com/g/golang-announce/c/sqrs2vTx_Xs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6866) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xODEuNyIsInVwZGF0ZWRJblZlciI6IjQ0LjE4MS43IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/1356
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-10-28 06:33:58 +00:00
Lunny XiaoandZettat123 ffd80101a3 enhance(kubernetes): gzip copies into job containers over exec (#1355)
The Kubernetes backend copies actions and other files into the job container with `tar` over `pods/exec`, so the data goes through the API server, which on managed clusters often sits in other zones than the nodes (each copied byte then crosses zones twice). Actions are megabytes of JavaScript bundles that gzip about fivefold:

| Action | tar | gzip |
|---|---|---|
| actions/setup-go@v5 | 8.0 MB | 1.5 MB |
| actions/cache@v4 | 11.6 MB | 2.0 MB |
| actions/setup-java@v4 | 15.3 MB | 2.5 MB |
| docker/build-push-action@v6 | 12.9 MB | 3.0 MB |

The startup probe now also reports whether the job container has `gzip`. If it does, `CopyDir` and copies of 64 KiB or more are gzipped at `BestSpeed` and extracted with `gzip -dc | tar -xf -`. Smaller copies (run scripts, file command files) stay uncompressed, where gzip would save next to nothing and cost a process in the pod. Images without `gzip` keep the plain tar stream, so the image requirements do not change.

This is independent of #1354 and gets most of its traffic saving with no new moving parts.

---
*Written by Codet (AI coding agent) on behalf of @lunny.*

---------

Co-authored-by: Zettat123 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1355
Reviewed-by: Zettat123 <[email protected]>
2026-10-28 06:00:51 +00:00
b712c91c2d test(e2e): cover host mode, reusable workflows, scheduling, reconnect and CLI flags (#1347)
Extends the e2e suite with scenarios for host labels, local `workflow_call`, label routing, `runner.capacity`, a dropped and restored connection to Gitea, and the daemon's `--once`, `--labels` and `shutdown_timeout` behaviour.

Stacked on https://gitea.com/gitea/runner/pulls/1346, so its commit shows here until that merges. The new scenarios pass locally against gitea/gitea:latest and main-nightly.

Assisted-by: Codet:codet
Co-authored-by: Zettat123 <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1347
Reviewed-by: Zettat123 <[email protected]>
2026-10-28 04:32:50 +00:00
d77e216674 fix(exec): serve the v4 artifact API so upload-artifact v4 and later work (#1341)
`exec` forwarded the toolkit's ArtifactService calls to an instance it never registered, and its random runtime token held no backend IDs, so `actions/upload-artifact` and `actions/download-artifact` v4 and later failed.

The local artifact server now serves the v4 API, including the single Put Blob uploads docker/build-push-action uses. `exec` gives each workflow its own unsigned JWT runtime token and artifact run, so artifact names conflict and carry over between runs as on GitHub. Artifacts default to a temporary directory instead of the workspace that jobs copy.

Co-authored-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1341
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-28 03:52:53 +00:00
Lunny Xiao a76ccd7020 test(e2e): cover the register/daemon CLI and failing jobs (#1346)
The e2e suite started the runner in-process and never exercised failing jobs. This adds a scenario that builds the binary and drives `register` then `daemon` against the Gitea fixture, and one for failed, skipped, continue-on-error and timed-out jobs.

Verified locally against gitea/gitea:main-nightly.

Assisted-by: Codet:codet
Reviewed-on: https://gitea.com/gitea/runner/pulls/1346
Reviewed-by: Zettat123 <[email protected]>
2026-10-27 21:23:59 +00:00
3dbed63f55 fix: close the action header group before the action runs (#1345)
`uses:` steps and job hooks kept their "Run …" group open until they finished. Gitea doesn't nest groups, so the action's own first `::group::` closed the header and everything before it stayed hidden there, e.g. all of goreleaser's output in https://gitea.com/gitea/cli/actions/runs/1121069/jobs/1519275. The header group now closes right after its details, as in actions/runner.

The action header also matches actions/runner's otherwise now: it is titled by the `uses:` reference, lists the resolved inputs including `action.yml` defaults without empty ones, is shown for pre stages too, and post stages start with `Post job cleanup.`.

---------

Co-authored-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1345
Reviewed-by: silverwind <[email protected]>
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-26 17:42:16 +00:00
silverwindandbircni dafe8d8d0c fix: keep dry runs off the docker daemon and speed up the tests (#1349)
Without a docker daemon six `act/runner` tests failed instead of passing. A dry run still created and removed job networks, the job teardown dropped the dry-run flag along with every other context value, and the container mocks asked the daemon for the runner arch.

This also speeds up the tests and CI without dropping assertions. Most of the time went to git and docker round trips, which are now pinned, batched or coalesced, and the dind and e2e steps cache or overlap more of their setup. On CI, `act/runner` went from 77s to 55s and `test-dind` from 108s to 55s. Locally, `act/runner` without docker went from 53s to 7s.

Split out of https://gitea.com/gitea/runner/pulls/1348, which adds Windows CI on top. The go cache changes moved to https://gitea.com/gitea/runner/pulls/1350.

Written by Claude Code.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1349
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-26 17:25:17 +00:00
Zettat123 8ae48ae033 fix: keep the shared action cache as extracted, re-fetch damaged trees (#1352)
The toolkit patch rewrote bundles in place in the shared action cache. Since https://gitea.com/gitea/runner/pulls/1316 trees are never refreshed, so a bundle left truncated failed every later job using it.

Patched bundles now only overwrite the job container's copy, so the runner no longer writes to an extracted tree.

Extraction records each file's size beside the tree, and a tree that no longer matches is fetched again, offline mode included, as a missing one always was. This also catches damage from outside the runner, such as storage losing a write, for a stat per file, a fraction of the copy each stage already makes. Trees from older runners have no record and download once more, except in offline mode, which keeps using them as before.

Generated with Claude Code.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1352
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-10-26 17:20:53 +00:00
Renovate Bot 8dd3776014 chore(deps): update dependencies (#1351)
Co-authored-by: Renovate Bot <[email protected]>
2026-10-26 07:06:33 +00:00
silverwind c6ca020281 ci: implement gitea's cache-seeder (#1350)
Like Gitea's CI, only a `cache-seeder` workflow on main saves the go caches, all other jobs only restore them. Caches are scoped per repository, so until now any pull request, forks included, could write a cache that other runs restore. The seeder compiles the tests without running them, so no test result is ever cached.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1350
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-24 17:04:51 +00:00
silverwind fe2a66dc25 fix: align runner behaviours (#1338)
Contains a number of small bug fixes where jobs behaved differently from GitHub or failed silently.

1. Hooks fail on a refused `::set-env::` or `::add-path::` and no longer inherit the last step's identity
1. Docker build, exec and wait errors are returned instead of swallowed, token-only registry credentials work, logs of `--tty` services decode
1. Env files split lines like GitHub on each OS, so a carriage return is kept on Linux and CRLF ends a line on Windows, empty heredoc names or delimiters are rejected
1. Post steps run after their pre step even when main was skipped, composites see undeclared inputs, `runs.args` wins without `with.args` being parsed
1. Docker actions skip evaluating defaults of inputs given in `with` and read `entryPoint` and `args` in any case, a lowercase `dockerfile` is found, undeclared inputs get GitHub's warning
1. A user-set `CI` is kept, `GITHUB_REPOSITORY_ID` and the other missing `GITHUB_*` variables are exported
1. `timeout-minutes` can read the step's own `env`, a step-level `ACTIONS_ALLOW_UNSECURE_COMMANDS: false` beats a job-level `true` while the runner's own opt-in still wins, `pwsh` quotes the script path
1. A step `env:` given as one expression such as `${{ fromJSON(matrix.env) }}` is applied, `hashFiles` fails with GitHub's messages on script errors and after 120 seconds, a job image without node still gets an empty hash
1. A step whose command files cannot be reset fails instead of importing stale ones, a timeout between steps fails the job instead of reporting success
1. One named volume can be mounted at several paths, host-mode PTY steps keep their last output when they fail, host scratch directories are removed when job setup fails
1. Cancelling a step right after it starts kills its whole process tree
1. Cache prefix restores pick the newest entry created within the same second, also on S3, cache commits with a wrong size are rejected
1. Artifact chunks are written at their offset, download URLs are escaped, listing a run without artifacts no longer crashes
1. Poll backoff reaches `fetch_interval_max`, proxy passwords are masked in more forms, secrets are also masked in step names and in their quoted form in the runner log, a failed final report is logged
1. On Windows hosts a differently cased unsecure-commands opt-in or proxy variable counts, and an early cancel no longer leaks a job object handle
1. Interactive `register` prompts for a missing token and stops on SIGTERM, `exec --list --detect-event` no longer panics without events, `config set` works on `cache.s3` keys

Reviewed-on: https://gitea.com/gitea/runner/pulls/1338
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-23 09:18:24 +00:00
Zettat123 c719a88f36 fix(k8s): retry the startup exec until the kubelet accepts it (#1337)
A new node can run pods before its kubelet has a serving certificate, so the first exec into the job container fails with `error dialing backend: remote error: tls: internal error` and the job fails during setup.

That exec only reads the arch and PATH, so it now retries for up to a minute unless the API server rejects it (4xx) or the command ran.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1337
Reviewed-by: bircni <[email protected]>
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-10-22 20:59:48 +00:00
silverwind d882311ec6 refactor: remove dead code and consolidate tests (#1335)
Removes dead code and redundant logic and consolidates tests, so there is less to read and maintain. No behavior change.

1. **act/runner**: inline single-use wrappers, compile regexes once instead of per call, embed the trampoline as a string, drop the unused legacy result of `parseActionCommand`
2. **act/container**: `sanitizeConfig` and `mergeContainerConfigs` stop returning the configs they already mutate in place, drop never-set option fields and the unused `user` parameter of `ExecWithCmdLine`
3. **act/common and cache server**: drop the unused `Trace`/`Tracef` loggers and the short SHA of `FindGitRevision`, `RegisterJob` releases through `RevokeJob`
4. **daemon, poller and reporter**: one shutdown wait for both poll modes, drop an unreachable fetch timeout branch, guard the reporter's last report time with its existing lock instead of an atomic
5. **build**: drop unused Makefile and release workflow variables, collapse the goreleaser ignore list to the same build targets
6. **tests**: fold near-duplicate tests into tables with shared setup

Throughout, hand-rolled logic gives way to `cmp.Or`, `strings.Cut`, `slices` and `min`/`max`.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1335
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-22 19:41:10 +00:00
silverwind 923a0bfb59 refactor: remove bolthold and gofakes3 deps (#1336)
1. Replace bolthold with direct bbolt access in the cache server. bolthold has not changed since March 2024 and kept `encoding/gob` in the binary although the store was already JSON. The on-disk layout stays the same, so existing caches keep working, and the index buckets bolthold left behind are dropped on the first sweep. Queries now run against an in-memory copy of the entries that is only re-read after another process wrote to the store, so lookups and sweeps no longer decode the whole store.

2. Replace gofakes3 with a small in-memory S3 fake in the one test that used it, which removes aws-sdk-go-v2, afero, mgo and x/tools from the module graph.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1336
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-22 16:28:49 +00:00
silverwind 6f0d4f1b48 refactor: migrate to connect-go v2 (#1325)
Migrates the runner to `connectrpc.com/connect/v2` by pinning actionslib v1.5.0, which carries https://gitea.com/gitea/actionslib/pulls/28. The wire behaviour towards Gitea is unchanged and works against Gitea servers on connect v1 and v2. The client keeps v1's unlimited response size, and the error codes and messages the runner checks stay the same.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1325
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-21 16:10:58 +00:00
silverwindandbircni 430f7bb2cf fix: handle workflow commands like GitHub's runner (#1333)
Commands the runner applies itself, like the `::set-output::` older actions still print, showed up raw in the job log. The reporter also re-parsed every line with one job-wide stop-commands and echo state, while act keeps one command handler per step, stage, composite child and hook, as GitHub does. A `::stop-commands::` left active in one of them kept the reporter suppressed for the others, so their `::add-mask::` values were never registered, and post steps logged command lines twice.

- act's command handler decides what reaches the log, as GitHub's runner does: applied commands only under `ACTIONS_STEP_DEBUG` or `::echo::on`, `::stop-commands::` and its resume line shown with long tokens masked
- Masks go straight to the reporter, which no longer parses command lines
- One parser follows GitHub's rules: leading whitespace, `##[...]` anywhere in a line, registered commands only
- Service logs honour only `::add-mask::`

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1333
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-21 16:03:40 +00:00
silverwind befb1b4bb9 fix: improve the actions toolkit patching (#1328)
Patch the scripts beside each node action entrypoint too, so code-split bundles like rust-cache v2.9.2, setup-java v6 and setup-deno v2 reach the v2 cache API. Entrypoints may point anywhere in the action's repository, as `gradle/actions/setup-gradle` does with `../dist`.

The `.LOCALHOST` match is now anchored on the `.GHE.COM` test before it, so another library's uppercase localhost check is never rewritten.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1328
Reviewed-by: Lunny Xiao <[email protected]>
2026-10-21 05:26:32 +00:00
silverwind 1a7f09224f perf: speed up job setup, action preparation and the cache server (#1327)
- Look up containers by name instead of listing all of them
- On Linux, cache the docker proxy discovery instead of starting a probe container per job
- Download a job's actions concurrently and share identical fetches between concurrent jobs
- Cache `runner.arch` per run and poll service health every 250ms
- Cache server: queue on an in-process lock instead of polling the database file lock, upload S3 objects up to 64MiB in one request and larger ones in 4 concurrent parts

| `exec`, median of 5 | Before | After |
|---|---:|---:|
| 1 step | 1.70s | 0.87s |
| 10 steps | 2.84s | 1.97s |
| Service + step | 3.78s | 2.18s |
| checkout + Node action | 3.64s | 2.52s |

Reviewed-on: https://gitea.com/gitea/runner/pulls/1327
Reviewed-by: Zettat123 <[email protected]>
2026-10-21 05:12:54 +00:00
bircni af2b3f6df5 fix(deps): update go toolchain directive to v1.27.3 (#1332)
Go 1.27.3 includes a crypto/x509 security fix that distrusts a set of intermediate certificates (https://github.com/golang/go/issues/82345), so release builds need the new toolchain.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1332
Reviewed-by: TheFox0x7 <[email protected]>
2026-10-20 19:31:44 +00:00
silverwindandbircni 785313cc21 build: drop the xgo release targets (#1329)
Releases are built by goreleaser with `CGO_ENABLED=0`, so the xgo `release*` Makefile targets and their gxz compression are unused. Gitea dropped xgo the same way in https://github.com/go-gitea/gitea/pull/37828.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1329
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-20 16:21:47 +00:00
silverwind 41f1e08bec chore(deps): bump the pins Renovate does not update (#1330)
Renovate gets no release timestamps for Docker Hub images with more than 1000 tags (https://github.com/renovatebot/renovate/issues/46925) or for quay.io, so the 5-day minimum release age holds them back forever. The e2e workflows, the Kubernetes examples and the toolkit test pins are outside its managers, so these are bumped by hand.

The builder follows the current Alpine instead of the frozen 3.23.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1330
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-20 16:14:16 +00:00
silverwindandbircni 4158e05dae refactor: drop logrus and other direct dependencies (#1319)
After https://gitea.com/gitea/runner/pulls/1304, logrus stayed only to forward logs from docker/cli and moby/go-archive. The runner used a small part of those: volume parsing, the build context tar, the `ssh://` connection and registry credentials. Own code that matches them replaces them, so logrus leaves the module graph. docker/cli stays for its logrus-free `opts` and `connhelper/ssh` packages.

Other dependencies that the standard library or existing ones cover go too: mergo, httprouter (replaced by `http.ServeMux`), retry-go, go-isatty, go-selinux, container-device-interface, gotest.tools and go-cmp (replaced by testify). The binary shrinks by 5%.

Behaviour differences:

1. An unreadable file in a build context is skipped with a warning, where go-archive left a truncated tar.
2. Build contexts no longer carry `security.capability` xattrs.
3. The cache and artifact servers answer HEAD on GET routes, and no longer redirect trailing slashes or answer OPTIONS.

*Written by Claude.*

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1319
Reviewed-by: bircni <[email protected]>
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-18 18:57:08 +00:00
silverwind e356c2ed85 chore(config): update stale go-yaml wrapping comment (#1324)
https://github.com/yaml/go-yaml/pull/441 fixed the wrapping bug this comment waited for, so reword it. `WithLineWidth(-1)` stays, `config edit` re-encodes the whole file and wrapping would reflow every long value.

Gitea: https://github.com/go-gitea/gitea/pull/39844

This PR was written with Claude.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1324
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-18 13:15:28 +00:00
silverwindandbircni ed940db4f0 fix: don't fail host steps over a leftover holding their output (#1323)
A successful host step whose background child kept its output open failed with `exec: WaitDelay expired before I/O complete`. Like GitHub's runner (`ProcessExitedHandler` in https://github.com/actions/runner/blob/main/src/Runner.Sdk/ProcessInvoker.cs), the step now succeeds after the drain timeout.

The leftover is killed only on Windows, as GitHub does there. A step whose timeout or cancellation hits during the drain still fails.

Fixes: https://gitea.com/gitea/runner/issues/1290
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1323
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-17 09:03:48 +00:00
Zettat123andsilverwind 755af87095 enhance(k8s): reach Kubernetes services by their id when it is a DNS subdomain (#1320)
On Kubernetes a service is reached by its id made a DNS label, so some service ids might become unreachable. For example, the `devstoreaccount1.azurite.local` service of Gitea's [MSSQL tests](https://github.com/go-gitea/gitea/blob/2fe74d3cdd5184a7e22e32e47772caa78d077489/.github/workflows/pull-db-tests.yml#L229) is only in the pod's hosts as `devstoreaccount1-azurite-local`, while the tests connect to the id as written. Container names must be DNS labels, but `hostAliases` accepts DNS subdomains, so an id that is one is now added there too, and to `no_proxy`, as on Docker where a service is reached by its id.

The warning for a service not reachable by its id now also covers ids that differ only in case, as Kubernetes hostnames are lowercase.

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1320
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: bircni <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-10-17 04:09:17 +00:00
silverwind b9aa47d1a5 fix(deps): bump go-yaml to fix block and folded scalar round-trips (#1321)
Bumps go-yaml to its main branch and drops the workaround it makes obsolete, matching https://github.com/go-gitea/gitea/pull/39787.

- go-yaml main keeps block scalar indentation indicators (https://github.com/yaml/go-yaml/pull/414), so values with a leading newline no longer need quoting
- `config edit` no longer wraps lines, because go-yaml still wraps more-indented lines of folded scalars and turns a space into a newline

Reviewed-on: https://gitea.com/gitea/runner/pulls/1321
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-16 21:34:11 +00:00
silverwind 0d84ec6dab fix: remove the job network even when container removal overruns (#1318)
Job teardown ran every step on one shared minute. When removing the job container took longer, the network removal ran on an expired context and the network stayed behind until the orphan reclaim, which only takes networks older than `workdir_cleanup_age`, 24h by default. All that time it held a subnet of the daemon's address pool.

The job and service containers are now removed in parallel, and the docker proxy sweep, the network and the volumes get a fresh budget after them. The idle reclaim takes unused networks and volumes once they are 10 minutes old, and network removal treats a network that is already gone as removed.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1318
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-15 14:15:41 +00:00
silverwind b769a9765f perf: download actions as archives once per job (#1316)
Each step refreshed its own clone of an action, so a composite using one action repository ten times fetched it ten times per job, and a single network error failed the job.

Like actions/runner, a job now downloads each action repository and ref once, as an archive extracted into a directory per commit. Each action still gets its own copy in the job container. This restores the per-job dedupe of https://gitea.com/gitea/runner/pulls/1178, which only reached a since-deleted branch.

How a download works:

1. The action URL is taken after `insteadOf` rewriting, as git would use it.
2. The archive comes from `<url>/archive/<ref>.tar.gz`, or from codeload.github.com for github.com, as for actions/runner.
3. A cached tag or branch is revalidated with its ETag, which GitHub and Gitea 28+ answer with a 304. A cached commit pin needs no request.
4. Transient failures (5xx, 408, 429 honouring `Retry-After`, dropped transfers) are retried three times with a 10 to 30 second backoff.
5. Git takes over, cloning and exporting the commit into the same directory layout, when the URL is not HTTP(S), the ref is a short commit, the host serves no usable archive, the host cannot be connected to, or the retries run out.

Median of 3 runs, git (shallow clone, then fetch) against archives, on a cold and a warm cache:

| Action | git cold | git warm | archive cold | archive warm |
|---|---|---|---|---|
| github.com actions/checkout@v4 | 937ms | 726ms | 286ms | 171ms |
| github.com actions/cache@v4 | 1006ms | 801ms | 516ms | 161ms |
| github.com actions/setup-node@v4 | 1046ms | 786ms | 463ms | 156ms |
| gitea.com actions/checkout@v4 | 2007ms | 1165ms | 667ms | 222ms |

The action cache layout changes, so existing caches download once more.

*Written by Claude.*

Fixes: https://gitea.com/gitea/runner/issues/1220
Reviewed-on: https://gitea.com/gitea/runner/pulls/1316
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-15 12:49:37 +00:00
9c4c66e70f enhance(report): add runner.log_report_level for the uploaded runner messages (#1310)
The runner's own messages between steps (not step output) were uploaded to the job log at `log.level`, so raising `log.level` for local debugging also changed what workflow authors see in Gitea, and lowering it hid messages from them.

Add `runner.log_report_level` to set that independently. Unset, it still follows `log.level`, so existing setups behave as before. An invalid value is rejected when the config loads.

Assisted-by: Codet
Assisted-by: Claude Code:claude-opus-5-5
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1310
Co-authored-by: Lunny Xiao <[email protected]>
2026-10-14 14:15:26 +00:00
silverwind 42858c61df fix: stop pruning the cached action ref on refresh (#1312)
The action cache refresh fetched with `--prune` and the unqualified refspec `+<ref>:refs/runner/<hash>`. Git cannot map that ref back to a remote ref, so it deleted it on every other refresh. The first job after a branch or tag moved then failed with `cannot lock ref`, and a moved tag in a full clone could silently run its old commit. `--no-prune` fixes this and also overrides an inherited `fetch.prune`.

Fixes: https://gitea.com/gitea/runner/issues/1311
Reviewed-on: https://gitea.com/gitea/runner/pulls/1312
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: Zettat123 <[email protected]>
2026-10-14 13:15:01 +00:00
470969dc74 chore(deps): update actionslib to v1.4.1 (#1313)
actionslib v1.4.1 no longer logs through logrus, so the workflow model no longer needs the logrus bridge. The bridge stays for docker/cli and moby/go-archive, which still log through logrus's global logger; the comment now says so.

actionslib now logs through slog's global logger, which the runner did not capture. A new slog bridge forwards it to the process-wide logger, so its messages keep the runner's level, format and secret masking.

Assisted-by: Codet
Co-authored-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1313
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
2026-10-14 12:09:48 +00:00
silverwind be7ddcd15a fix: give each docker proxy its own job label key (#1314)
A runner nested behind another runner's docker proxy, as in gitea.com CI, had its `com.gitea.runner.job` label overwritten by the outer proxy, so its job cleanup removed nothing. This failed `TestDockerProxyWithDaemon` depending on the CI host, for example on https://gitea.com/gitea/runner/pulls/1312.

Each proxy now sets `com.gitea.runner.job.<job>` and cleans up by that key, so nested proxies keep each other's. This replaces the `com.gitea.runner.job=<job>` label.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1314
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-14 08:00:17 +00:00
Lunny Xiaoandbircni c3c647ef83 test: use t.Context() instead of context.Background() (#1306)
The test context is cancelled when the test ends, so anything still holding it stops instead of leaking into the next test. Uses inside `t.Cleanup`, `defer` and `go func` blocks are left alone, as the context is already cancelled there.

Stacked on https://gitea.com/gitea/runner/pulls/1305.

Assisted-by: Codet
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1306
Reviewed-by: bircni <[email protected]>
2026-10-12 21:11:17 +00:00
bircni 160e18e705 enhance: report the steps of a task as they run (#1293)
Gitea derives the steps of a job from the workflow file, so the `pre` and `post` steps of actions only show up inside "Set up job" and "Complete job", see https://github.com/go-gitea/gitea/issues/24604.

The runner now also reports the steps as it runs them (https://gitea.com/gitea/actions-proto-def/pulls/19): "Set up job", a "Pre" and "Post" step for each action that has one, the workflow steps with their evaluated names, and "Complete job", as actions/runner shows them. The per-step state Gitea reads today is unchanged, and the job log only moves "Complete job name" and "Matrix:" ahead of the pre steps, so older Gitea versions keep showing everything as before. Job hooks and containers stay part of "Set up job" and "Complete job".

The `replace` of `gitea.dev/actionslib` is swapped for the release once the proto change is merged.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1293
Reviewed-by: Zettat123 <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-12 20:35:02 +00:00
silverwind 20c8b4abc4 refactor: replace go-git with the git CLI for gitignore filtering (#1309)
Replace go-git in `exec --use-gitignore` filtering with `git ls-files`. The git CLI is already required for action clones since https://gitea.com/gitea/runner/pulls/1277, so this drops `go-git` and 14 other modules, and filtering now follows git's own ignore rules.

Also stop deleting a remote action's `.gitignore` before copying it. Tracked files are never filtered, so the deletion only made copied actions differ from GitHub's action tarballs.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1309
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-12 18:13:41 +00:00
Renovate Botandsilverwind ccb9afd7a5 chore(deps): update dependencies (#1308)
This PR contains the following updates:

| Package | Type | Update | Change | Pending | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|---|---|
| [actions/setup-node](https://github.com/actions/setup-node) ([changelog](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020..949feb2413d6458794dcd2491c4babbbce0c15c1)) | action | digest | `8207627` → `949feb2` |  |  |  |
| [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) | require | patch | `v5.19.2` → `v5.19.3` |  | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgo-git%2fgo-git%2fv5/v5.19.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgo-git%2fgo-git%2fv5/v5.19.2/v5.19.3?slim=true) |
| [github.com/moby/moby/api](https://github.com/moby/moby) | require | patch | `v1.56.0` → `v1.56.1` |  | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fmoby%2fapi/v1.56.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fmoby%2fapi/v1.56.0/v1.56.1?slim=true) |
| [github.com/moby/moby/client](https://github.com/moby/moby) | require | patch | `v0.6.0` → `v0.6.1` | `v0.6.2` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fmoby%2fclient/v0.6.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fmoby%2fclient/v0.6.0/v0.6.1?slim=true) |
| [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) | require | minor | `v1.46.0` → `v1.47.0` |  | ![age](https://developer.mend.io/api/mc/badges/age/go/go.opentelemetry.io%2fotel/v1.47.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.opentelemetry.io%2fotel/v1.46.0/v1.47.0?slim=true) |
| [go.opentelemetry.io/otel/exporters/otlp/otlptrace](https://github.com/open-telemetry/opentelemetry-go) | require | minor | `v1.46.0` → `v1.47.0` |  | ![age](https://developer.mend.io/api/mc/badges/age/go/go.opentelemetry.io%2fotel%2fexporters%2fotlp%2fotlptrace/v1.47.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.opentelemetry.io%2fotel%2fexporters%2fotlp%2fotlptrace/v1.46.0/v1.47.0?slim=true) |
| [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) | require | minor | `v1.46.0` → `v1.47.0` |  | ![age](https://developer.mend.io/api/mc/badges/age/go/go.opentelemetry.io%2fotel%2fsdk/v1.47.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.opentelemetry.io%2fotel%2fsdk/v1.46.0/v1.47.0?slim=true) |
| [go.opentelemetry.io/otel/trace](https://github.com/open-telemetry/opentelemetry-go) | require | minor | `v1.46.0` → `v1.47.0` |  | ![age](https://developer.mend.io/api/mc/badges/age/go/go.opentelemetry.io%2fotel%2ftrace/v1.47.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.opentelemetry.io%2fotel%2ftrace/v1.46.0/v1.47.0?slim=true) |
| [go.opentelemetry.io/proto/otlp](https://github.com/open-telemetry/opentelemetry-proto-go) | require | patch | `v1.11.0` → `v1.11.1` |  | ![age](https://developer.mend.io/api/mc/badges/age/go/go.opentelemetry.io%2fproto%2fotlp/v1.11.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.opentelemetry.io%2fproto%2fotlp/v1.11.0/v1.11.1?slim=true) |
| [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) | require | minor | [`v0.23.0` → `v0.24.0`](https://cs.opensource.google/go/x/sync/+/refs/tags/v0.23.0...refs/tags/v0.24.0) |  | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fsync/v0.24.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fsync/v0.23.0/v0.24.0?slim=true) |
| [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) | require | minor | [`v0.48.0` → `v0.49.0`](https://cs.opensource.google/go/x/sys/+/refs/tags/v0.48.0...refs/tags/v0.49.0) |  | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fsys/v0.49.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fsys/v0.48.0/v0.49.0?slim=true) |
| [node](https://github.com/nodejs/node) |  | digest | `0e0ff40` → `d6aa754` |  |  |  |

---

### Release Notes

<details>
<summary>go-git/go-git (github.com/go-git/go-git/v5)</summary>

### [`v5.19.3`](https://github.com/go-git/go-git/releases/tag/v5.19.3)

[Compare Source](https://github.com/go-git/go-git/compare/v5.19.2...v5.19.3)

#### What's Changed

- \[v5] git: repository, Ensure commondir is closed. by [@&#8203;arcaven](https://github.com/arcaven) in [#&#8203;2285](https://github.com/go-git/go-git/pull/2285)
- config: read repositoryformatversion in unmarshalCore by [@&#8203;c-tonneslan](https://github.com/c-tonneslan) in [#&#8203;2167](https://github.com/go-git/go-git/pull/2167)
- build: Update module github.com/go-git/go-git/v5 to v5.19.2 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2304](https://github.com/go-git/go-git/pull/2304)
- \[v5] plumbing: revlist, Skip path validation in the object walk by [@&#8203;r0h1tb](https://github.com/r0h1tb) in [#&#8203;2305](https://github.com/go-git/go-git/pull/2305)
- build: Update module golang.org/x/crypto to v0.55.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2348](https://github.com/go-git/go-git/pull/2348)
- \[v5] plumbing: transport/http, Contain credentials across redirects by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2358](https://github.com/go-git/go-git/pull/2358)
- build: Fix workflows for Go 1.27 (v5) by [@&#8203;pjbgf](https://github.com/pjbgf) in [#&#8203;2364](https://github.com/go-git/go-git/pull/2364)
- plumbing: format/packfile, Stop running TestChecksumMismatch and TestMalformedPack in parallel by [@&#8203;akashchamp](https://github.com/akashchamp) in [#&#8203;2421](https://github.com/go-git/go-git/pull/2421)
- build: Update module golang.org/x/crypto to v0.56.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2357](https://github.com/go-git/go-git/pull/2357)
- plumbing: format/packfile, Break cyclic delta chains instead of recur… by [@&#8203;jgoosens](https://github.com/jgoosens) in [#&#8203;2340](https://github.com/go-git/go-git/pull/2340)
- build: Drop Go 1.25 by [@&#8203;pjbgf](https://github.com/pjbgf) in [#&#8203;2451](https://github.com/go-git/go-git/pull/2451)
- build: Update module github.com/go-git/go-billy/v5 to v5.9.2 (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2467](https://github.com/go-git/go-git/pull/2467)
- build: Bump pjbgf/sha1cd to v0.7.0 by [@&#8203;pjbgf](https://github.com/pjbgf) in [#&#8203;2468](https://github.com/go-git/go-git/pull/2468)

#### New Contributors

- [@&#8203;arcaven](https://github.com/arcaven) made their first contribution in [#&#8203;2285](https://github.com/go-git/go-git/pull/2285)
- [@&#8203;c-tonneslan](https://github.com/c-tonneslan) made their first contribution in [#&#8203;2167](https://github.com/go-git/go-git/pull/2167)
- [@&#8203;r0h1tb](https://github.com/r0h1tb) made their first contribution in [#&#8203;2305](https://github.com/go-git/go-git/pull/2305)
- [@&#8203;akashchamp](https://github.com/akashchamp) made their first contribution in [#&#8203;2421](https://github.com/go-git/go-git/pull/2421)
- [@&#8203;jgoosens](https://github.com/jgoosens) made their first contribution in [#&#8203;2340](https://github.com/go-git/go-git/pull/2340)

**Full Changelog**: <https://github.com/go-git/go-git/compare/v5.19.2...v5.19.3>

</details>

<details>
<summary>moby/moby (github.com/moby/moby/client)</summary>

### [`v0.6.1`](https://github.com/moby/moby/compare/v0.6.0...v0.6.1)

[Compare Source](https://github.com/moby/moby/compare/v0.6.0...v0.6.1)

</details>

<details>
<summary>open-telemetry/opentelemetry-go (go.opentelemetry.io/otel)</summary>

### [`v1.47.0`](https://github.com/open-telemetry/opentelemetry-go/releases/tag/v1.47.0): /v0.69.0/v0.23.0/v0.1.0

[Compare Source](https://github.com/open-telemetry/opentelemetry-go/compare/v1.46.0...v1.47.0)

This release contains the first stable release of the OpenTelemetry Go Logs API and SDK.
Our project stability guarantees now apply to the following modules:

- `go.opentelemetry.io/otel/log`
- `go.opentelemetry.io/otel/sdk/log`

See our [versioning policy](https://github.com/open-telemetry/opentelemetry-go/blob/main/VERSIONING.md) for more information about these stability guarantees.

##### Added

- Add `String` method for `KeyValue` type in `go.opentelemetry.io/otel/attribute`. ([#&#8203;8205](https://github.com/open-telemetry/opentelemetry-go/issues/8205))
- Add `String` method for `Set` type in `go.opentelemetry.io/otel/attribute`. ([#&#8203;8347](https://github.com/open-telemetry/opentelemetry-go/issues/8347))
- Add `WithMaxExportBatchSize` to `go.opentelemetry.io/otel/sdk/metric` to configure the maximum export batch size for `PeriodicReader`. ([#&#8203;8960](https://github.com/open-telemetry/opentelemetry-go/issues/8960))
- Add `WithAttributeValueDepthLimit`, `DefaultAttributeValueDepthLimit`, and `SpanLimits.AttributeValueDepthLimit` in `go.opentelemetry.io/otel/sdk/trace` to configure the maximum depth of span, event, link, and instrumentation scope attribute values. ([#&#8203;8938](https://github.com/open-telemetry/opentelemetry-go/issues/8938))
- Add `WithAttributeValueDepthLimit` in `go.opentelemetry.io/otel/sdk/log` to configure the maximum depth of log record and instrumentation scope attribute values. ([#&#8203;8938](https://github.com/open-telemetry/opentelemetry-go/issues/8938))
- Add `WithMaxResponseSize` to `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp`,
  `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp`, and
  `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`. ([#&#8203;8941](https://github.com/open-telemetry/opentelemetry-go/issues/8941))
- Add experimental `ProbabilitySampler` in `go.opentelemetry.io/otel/sdk/trace/x` that conforms to the [OpenTelemetry specification's threshold-based sampling algorithm](https://opentelemetry.io/docs/specs/otel/trace/sdk/#probabilitysampler). ([#&#8203;8123](https://github.com/open-telemetry/opentelemetry-go/issues/8123))
- Add experimental `*Binder` extension interfaces in `go.opentelemetry.io/otel/metric/x` for instruments that support binding attributes ahead of time. ([#&#8203;8760](https://github.com/open-telemetry/opentelemetry-go/issues/8760))
- Add the experimental `Finisher` synchronous metric instrument extension interface to `go.opentelemetry.io/otel/metric/x`. ([#&#8203;8906](https://github.com/open-telemetry/opentelemetry-go/issues/8906))

##### Changed

- Use the [OpenTelemetry Attribute Collection representation for non-OTLP protocols](https://opentelemetry.io/docs/specs/otel/common/#attribute-collection-representation-for-non-otlp) for metric data diffs in `go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest`. ([#&#8203;8993](https://github.com/open-telemetry/opentelemetry-go/issues/8993))
- Apply a maximum depth of 64 by default to span, event, link, log record, and instrumentation scope attribute values in `go.opentelemetry.io/otel/sdk/trace` and `go.opentelemetry.io/otel/sdk/log`. ([#&#8203;8938](https://github.com/open-telemetry/opentelemetry-go/issues/8938))
- Reduce allocations when applying attribute value length limits in `go.opentelemetry.io/otel/sdk/trace` and `go.opentelemetry.io/otel/sdk/log` by rebuilding composite values only when truncation is needed. ([#&#8203;8912](https://github.com/open-telemetry/opentelemetry-go/issues/8912))
- Decode `traceparent` using a hex lookup table in `go.opentelemetry.io/otel/propagation`, which rejects the specification-disallowed upper-case characters without a separate scan of the header. ([#&#8203;8739](https://github.com/open-telemetry/opentelemetry-go/issues/8739))
- Decode all characters at constant indices in `TraceIDFromHex` and `SpanIDFromHex` in `go.opentelemetry.io/otel/trace` to eliminate bounds checks. ([#&#8203;8740](https://github.com/open-telemetry/opentelemetry-go/issues/8740))

##### Fixed

- Count valid U+FFFD replacement characters toward attribute value length limits in `go.opentelemetry.io/otel/trace`, `go.opentelemetry.io/otel/sdk/trace`, and `go.opentelemetry.io/otel/sdk/log`.
- Truncate string attribute values to empty strings when the configured length limit is zero, including malformed UTF-8, in `go.opentelemetry.io/otel/trace`, `go.opentelemetry.io/otel/sdk/trace`, and `go.opentelemetry.io/otel/sdk/log`. ([#&#8203;9054](https://github.com/open-telemetry/opentelemetry-go/issues/9054))
- Fix a data race in `NewPeriodicReader` where `r.inst` was assigned after the background goroutine was launched in `go.opentelemetry.io/otel/sdk/metric`. ([#&#8203;9028](https://github.com/open-telemetry/opentelemetry-go/issues/9028))
- Prevent precision loss for large `int64` values in `Sum`, `Histogram`, and `ExponentialHistogram` aggregations in `go.opentelemetry.io/otel/sdk/metric`. ([#&#8203;8981](https://github.com/open-telemetry/opentelemetry-go/issues/8981))
- Ensure `grpc.DialOption` values passed via `WithDialOption` take precedence over conflicting internally-computed defaults in
  `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc`,
  `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc`, and
  `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc`. ([#&#8203;8805](https://github.com/open-telemetry/opentelemetry-go/issues/8805), [#&#8203;8834](https://github.com/open-telemetry/opentelemetry-go/issues/8834), [#&#8203;8836](https://github.com/open-telemetry/opentelemetry-go/issues/8836))
- Treat overflowing `OTEL_METRIC_EXPORT_INTERVAL` and `OTEL_METRIC_EXPORT_TIMEOUT` values as invalid in `go.opentelemetry.io/otel/sdk/metric`. ([#&#8203;8800](https://github.com/open-telemetry/opentelemetry-go/issues/8800))
- Prevent a panic in `NewFixedSizeReservoir` and `FixedSizeReservoirProvider` when given a negative size in `go.opentelemetry.io/otel/sdk/metric/exemplar`; negative sizes are now clamped to zero, consistent with a size of zero. ([#&#8203;8832](https://github.com/open-telemetry/opentelemetry-go/issues/8832))
- Preserve exponential histogram `ZeroThreshold` during OTLP metric export in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric`. ([#&#8203;8801](https://github.com/open-telemetry/opentelemetry-go/issues/8801))
- Ensure metric helpers in `go.opentelemetry.io/otel/semconv/v1.32.0`, `go.opentelemetry.io/otel/semconv/v1.33.0`, and `go.opentelemetry.io/otel/semconv/v1.34.0` record measurements only once when no attributes are provided. ([#&#8203;8849](https://github.com/open-telemetry/opentelemetry-go/issues/8849))
- Treat empty `OTEL_TRACES_SAMPLER` and `OTEL_TRACES_SAMPLER_ARG` values as unset in `go.opentelemetry.io/otel/sdk/trace`. ([#&#8203;8873](https://github.com/open-telemetry/opentelemetry-go/issues/8873))
- Normalize global `OTEL_EXPORTER_OTLP_ENDPOINT` path joining in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp` so trailing-slash base URLs do not produce double-slash log export paths. ([#&#8203;8864](https://github.com/open-telemetry/opentelemetry-go/issues/8864))
- Prevent a deadlock when formatting an error passed to `RecordError` calls back into the span in `go.opentelemetry.io/otel/sdk/trace`. ([#&#8203;8815](https://github.com/open-telemetry/opentelemetry-go/issues/8815))
- Prevent `SimpleSpanProcessor.Shutdown` from panicking when constructed with a nil exporter in `go.opentelemetry.io/otel/sdk/trace`. ([#&#8203;8844](https://github.com/open-telemetry/opentelemetry-go/issues/8844))
- Propagate invalid exponential histogram scale errors to Prometheus exporter self-observability metrics in `go.opentelemetry.io/otel/exporters/prometheus`. ([#&#8203;8839](https://github.com/open-telemetry/opentelemetry-go/issues/8839))
- Retain instrument advisory attributes configured with `go.opentelemetry.io/otel/metric/x.WithDefaultAttributes` when a matching View in `go.opentelemetry.io/otel/sdk/metric` does not specify an attribute filter. ([#&#8203;8859](https://github.com/open-telemetry/opentelemetry-go/issues/8859))
- Ignore HTTP URL paths when building OTLP/gRPC metric exporter targets from `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, while preserving `unix://` and `unix-abstract://` targets; treat `unix-abstract://` endpoints as insecure in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc` and `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp`. ([#&#8203;8862](https://github.com/open-telemetry/opentelemetry-go/issues/8862))
- Ignore HTTP(S) paths when deriving gRPC trace exporter endpoints from `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc`. ([#&#8203;8852](https://github.com/open-telemetry/opentelemetry-go/issues/8852))
- Bound memory used while parsing dense W3C property metadata by storing it compactly and materializing `Property` values on demand in `go.opentelemetry.io/otel/baggage` and `go.opentelemetry.io/otel/propagation`.

##### Removed

- Remove the experimental `OTEL_GO_X_METRIC_EXPORT_BATCH_SIZE` environment variable in `go.opentelemetry.io/otel/sdk/metric`.
  Use the `WithMaxExportBatchSize` option instead. ([#&#8203;8960](https://github.com/open-telemetry/opentelemetry-go/issues/8960))

##### What's Changed

- Prevent panic in FixedSizeReservoir for negative sizes by [@&#8203;chiliec](https://github.com/chiliec) in [#&#8203;8832](https://github.com/open-telemetry/opentelemetry-go/pull/8832)
- fix(metric): reject overflowing export durations by [@&#8203;maithilijoshi20](https://github.com/maithilijoshi20) in [#&#8203;8800](https://github.com/open-telemetry/opentelemetry-go/pull/8800)
- chore(deps): update module github.com/gofrs/flock to v0.13.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8885](https://github.com/open-telemetry/opentelemetry-go/pull/8885)
- chore(deps): update module github.com/4meepo/tagalign to v1.4.4 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8900](https://github.com/open-telemetry/opentelemetry-go/pull/8900)
- fix(deps): update googleapis to [`18b4a75`](https://github.com/open-telemetry/opentelemetry-go/commit/18b4a75) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8907](https://github.com/open-telemetry/opentelemetry-go/pull/8907)
- fix(deps): update module github.com/prometheus/client\_model to v0.6.3 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8908](https://github.com/open-telemetry/opentelemetry-go/pull/8908)
- chore(deps): update module github.com/prometheus/procfs to v0.22.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8894](https://github.com/open-telemetry/opentelemetry-go/pull/8894)
- chore(deps): update module github.com/godoc-lint/godoc-lint to v0.11.4 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8897](https://github.com/open-telemetry/opentelemetry-go/pull/8897)
- chore(deps): update github.com/charmbracelet/ultraviolet digest to [`d60c349`](https://github.com/open-telemetry/opentelemetry-go/commit/d60c349) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8909](https://github.com/open-telemetry/opentelemetry-go/pull/8909)
- fix(deps): update module github.com/prometheus/common to v0.71.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8911](https://github.com/open-telemetry/opentelemetry-go/pull/8911)
- chore(deps): update golang.org/x/telemetry digest to [`86cb573`](https://github.com/open-telemetry/opentelemetry-go/commit/86cb573) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8895](https://github.com/open-telemetry/opentelemetry-go/pull/8895)
- fix(deps): update github.com/opentracing-contrib/go-grpc/test digest to [`7bb06b0`](https://github.com/open-telemetry/opentelemetry-go/commit/7bb06b0) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8902](https://github.com/open-telemetry/opentelemetry-go/pull/8902)
- chore(deps): update module github.com/timonwong/loggercheck to v0.12.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8898](https://github.com/open-telemetry/opentelemetry-go/pull/8898)
- \[otlptracegrpc] Grpc dialoption override bug by [@&#8203;ps-mir](https://github.com/ps-mir) in [#&#8203;8805](https://github.com/open-telemetry/opentelemetry-go/pull/8805)
- otlpmetricgrpc: fix preference for user supplied dialOptions by [@&#8203;ps-mir](https://github.com/ps-mir) in [#&#8203;8834](https://github.com/open-telemetry/opentelemetry-go/pull/8834)
- Do not panic on nil exporter in SimpleSpanProcessor.Shutdown by [@&#8203;ShreeyashPatil2708](https://github.com/ShreeyashPatil2708) in [#&#8203;8844](https://github.com/open-telemetry/opentelemetry-go/pull/8844)
- \[chore] Add doc comments to exported internal symbols (1/N) by [@&#8203;om7057](https://github.com/om7057) in [#&#8203;8752](https://github.com/open-telemetry/opentelemetry-go/pull/8752)
- \[chore] Add doc comments to exported internal symbols (3/N) by [@&#8203;om7057](https://github.com/om7057) in [#&#8203;8869](https://github.com/open-telemetry/opentelemetry-go/pull/8869)
- \[chore] docs: mark Logs as release candidate by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8910](https://github.com/open-telemetry/opentelemetry-go/pull/8910)
- chore(deps): update module github.com/mattn/go-runewidth to v0.0.29 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8917](https://github.com/open-telemetry/opentelemetry-go/pull/8917)
- chore(deps): update github.com/charmbracelet/ultraviolet digest to [`af8eda3`](https://github.com/open-telemetry/opentelemetry-go/commit/af8eda3) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8915](https://github.com/open-telemetry/opentelemetry-go/pull/8915)
- chore(deps): update github.com/golangci/rowserrcheck digest to [`241134d`](https://github.com/open-telemetry/opentelemetry-go/commit/241134d) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8913](https://github.com/open-telemetry/opentelemetry-go/pull/8913)
- fix(deps): update module go.opentelemetry.io/collector/pdata to v1.66.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8918](https://github.com/open-telemetry/opentelemetry-go/pull/8918)
- chore(deps): update otel/weaver docker tag to v0.26.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8922](https://github.com/open-telemetry/opentelemetry-go/pull/8922)
- chore(deps): update open-telemetry/shared-workflows action to v0.13.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8921](https://github.com/open-telemetry/opentelemetry-go/pull/8921)
- chore(deps): update github.com/charmbracelet/ultraviolet digest to [`043ea8a`](https://github.com/open-telemetry/opentelemetry-go/commit/043ea8a) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8919](https://github.com/open-telemetry/opentelemetry-go/pull/8919)
- fix(otlploggrpc): honor custom dial option precedence by [@&#8203;jstar0](https://github.com/jstar0) in [#&#8203;8836](https://github.com/open-telemetry/opentelemetry-go/pull/8836)
- Define bound instrument API in metric/x by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8760](https://github.com/open-telemetry/opentelemetry-go/pull/8760)
- fix(prometheus): report invalid histogram scale errors by [@&#8203;jstar0](https://github.com/jstar0) in [#&#8203;8839](https://github.com/open-telemetry/opentelemetry-go/pull/8839)
- Add experimental metric Finisher interface by [@&#8203;MrAlias](https://github.com/MrAlias) in [#&#8203;8906](https://github.com/open-telemetry/opentelemetry-go/pull/8906)
- chore(deps): update github.com/charmbracelet/ultraviolet digest to [`ae99b73`](https://github.com/open-telemetry/opentelemetry-go/commit/ae99b73) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8923](https://github.com/open-telemetry/opentelemetry-go/pull/8923)
- chore(deps): update golang.org/x by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8920](https://github.com/open-telemetry/opentelemetry-go/pull/8920)
- sdk/trace/x: Add experimental `ProbabilitySampler` by [@&#8203;yuanyuanzhao3](https://github.com/yuanyuanzhao3) in [#&#8203;8123](https://github.com/open-telemetry/opentelemetry-go/pull/8123)
- sdk/trace/x: Adding GoDev badge. by [@&#8203;yuanyuanzhao3](https://github.com/yuanyuanzhao3) in [#&#8203;8927](https://github.com/open-telemetry/opentelemetry-go/pull/8927)
- perf(propagation): use a hex lookup table to decode traceparent by [@&#8203;itssaharsh](https://github.com/itssaharsh) in [#&#8203;8739](https://github.com/open-telemetry/opentelemetry-go/pull/8739)
- chore(deps): update github.com/charmbracelet/ultraviolet digest to [`0277a17`](https://github.com/open-telemetry/opentelemetry-go/commit/0277a17) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8931](https://github.com/open-telemetry/opentelemetry-go/pull/8931)
- fix(deps): update googleapis to [`f8649dd`](https://github.com/open-telemetry/opentelemetry-go/commit/f8649dd) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8929](https://github.com/open-telemetry/opentelemetry-go/pull/8929)
- chore(deps): update module github.com/dlclark/regexp2/v2 to v2.8.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8930](https://github.com/open-telemetry/opentelemetry-go/pull/8930)
- \[chore] Add doc comments to exported internal symbols (5/N) by [@&#8203;om7057](https://github.com/om7057) in [#&#8203;8934](https://github.com/open-telemetry/opentelemetry-go/pull/8934)
- \[chore] Add doc comments to exported internal symbols (4/N) by [@&#8203;om7057](https://github.com/om7057) in [#&#8203;8926](https://github.com/open-telemetry/opentelemetry-go/pull/8926)
- sdk: optimize composite attribute truncation by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8912](https://github.com/open-telemetry/opentelemetry-go/pull/8912)
- chore(deps): update module github.com/go-critic/go-critic to v0.15.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8924](https://github.com/open-telemetry/opentelemetry-go/pull/8924)
- chore(deps): update module go.augendre.info/fatcontext to v0.10.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8939](https://github.com/open-telemetry/opentelemetry-go/pull/8939)
- chore(deps): update module mvdan.cc/gofumpt to v0.12.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8935](https://github.com/open-telemetry/opentelemetry-go/pull/8935)
- chore(deps): update module dev.gaijin.team/go/exhaustruct/v5 to v5.2.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8928](https://github.com/open-telemetry/opentelemetry-go/pull/8928)
- fix(deps): update golang.org/x by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8936](https://github.com/open-telemetry/opentelemetry-go/pull/8936)
- chore(deps): update module github.com/ghostiam/protogetter to v1.0.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8940](https://github.com/open-telemetry/opentelemetry-go/pull/8940)
- fix(trace): avoid reentrant RecordError deadlock by [@&#8203;jstar0](https://github.com/jstar0) in [#&#8203;8815](https://github.com/open-telemetry/opentelemetry-go/pull/8815)
- \[chore] Add doc comments to exported internal symbols (6/6), remove exclusion by [@&#8203;om7057](https://github.com/om7057) in [#&#8203;8937](https://github.com/open-telemetry/opentelemetry-go/pull/8937)
- otlploghttp: normalize global endpoint path joining by [@&#8203;RedZapdos123](https://github.com/RedZapdos123) in [#&#8203;8864](https://github.com/open-telemetry/opentelemetry-go/pull/8864)
- sdk/trace: treat empty sampler env values as unset by [@&#8203;RedZapdos123](https://github.com/RedZapdos123) in [#&#8203;8873](https://github.com/open-telemetry/opentelemetry-go/pull/8873)
- Run full benchmarks weekly by [@&#8203;XSAM](https://github.com/XSAM) in [#&#8203;8932](https://github.com/open-telemetry/opentelemetry-go/pull/8932)
- fix(semconv): avoid duplicate metric recording without attributes by [@&#8203;jstar0](https://github.com/jstar0) in [#&#8203;8849](https://github.com/open-telemetry/opentelemetry-go/pull/8849)
- chore(deps): update module github.com/mattn/go-runewidth to v0.0.30 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8946](https://github.com/open-telemetry/opentelemetry-go/pull/8946)
- chore(deps): update ubuntu:26.04 docker digest to [`e5a4d62`](https://github.com/open-telemetry/opentelemetry-go/commit/e5a4d62) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8945](https://github.com/open-telemetry/opentelemetry-go/pull/8945)
- chore(deps): update github/codeql-action action to v4.38.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8944](https://github.com/open-telemetry/opentelemetry-go/pull/8944)
- chore(deps): update golang.org/x/telemetry digest to [`55ac43c`](https://github.com/open-telemetry/opentelemetry-go/commit/55ac43c) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8943](https://github.com/open-telemetry/opentelemetry-go/pull/8943)
- otlptracegrpc: fix env endpoint URL parsing by [@&#8203;RedZapdos123](https://github.com/RedZapdos123) in [#&#8203;8852](https://github.com/open-telemetry/opentelemetry-go/pull/8852)
- otlpmetricgrpc: preserve gRPC env endpoint targets by [@&#8203;RedZapdos123](https://github.com/RedZapdos123) in [#&#8203;8862](https://github.com/open-telemetry/opentelemetry-go/pull/8862)
- chore(deps): update module github.com/sourcegraph/go-diff to v0.9.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8947](https://github.com/open-telemetry/opentelemetry-go/pull/8947)
- feat(otlphttp): configure maximum response size by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8941](https://github.com/open-telemetry/opentelemetry-go/pull/8941)
- fix(otlpmetric): preserve exponential histogram zero threshold by [@&#8203;maithilijoshi20](https://github.com/maithilijoshi20) in [#&#8203;8801](https://github.com/open-telemetry/opentelemetry-go/pull/8801)
- chore(deps): update golang.org/x/telemetry digest to [`15ceca2`](https://github.com/open-telemetry/opentelemetry-go/commit/15ceca2) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8948](https://github.com/open-telemetry/opentelemetry-go/pull/8948)
- chore(deps): update github.com/charmbracelet/ultraviolet digest to [`6c9e17d`](https://github.com/open-telemetry/opentelemetry-go/commit/6c9e17d) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8950](https://github.com/open-telemetry/opentelemetry-go/pull/8950)
- chore(deps): update module github.com/dave/dst to v0.28.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8949](https://github.com/open-telemetry/opentelemetry-go/pull/8949)
- fix(deps): update googleapis to [`f61a6ca`](https://github.com/open-telemetry/opentelemetry-go/commit/f61a6ca) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8951](https://github.com/open-telemetry/opentelemetry-go/pull/8951)
- chore(deps): update module github.com/nishanths/exhaustive to v0.13.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8953](https://github.com/open-telemetry/opentelemetry-go/pull/8953)
- chore(deps): update module github.com/xo/terminfo to v1.2.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8954](https://github.com/open-telemetry/opentelemetry-go/pull/8954)
- sdk/log: add queue\_full shutdown regression test by [@&#8203;RedZapdos123](https://github.com/RedZapdos123) in [#&#8203;8754](https://github.com/open-telemetry/opentelemetry-go/pull/8754)
- internal/tools/verifyreadmes: skip hidden dot-directories on Windows by [@&#8203;RedZapdos123](https://github.com/RedZapdos123) in [#&#8203;8729](https://github.com/open-telemetry/opentelemetry-go/pull/8729)
- sdk/metric: add internal finish-aware Sum lifecycle by [@&#8203;MrAlias](https://github.com/MrAlias) in [#&#8203;8925](https://github.com/open-telemetry/opentelemetry-go/pull/8925)
- sdk: limit nested attribute value depth by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8938](https://github.com/open-telemetry/opentelemetry-go/pull/8938)
- chore(deps): update codecov/codecov-action action to v7.1.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8966](https://github.com/open-telemetry/opentelemetry-go/pull/8966)
- fix(deps): update module go.opentelemetry.io/collector/pdata to v1.67.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8965](https://github.com/open-telemetry/opentelemetry-go/pull/8965)
- chore(deps): update open-telemetry/shared-workflows action to v0.14.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8961](https://github.com/open-telemetry/opentelemetry-go/pull/8961)
- Fix Alex Kats emeritus mention by [@&#8203;dmathieu](https://github.com/dmathieu) in [#&#8203;8933](https://github.com/open-telemetry/opentelemetry-go/pull/8933)
- sdk/metric: stabilize max batch size in PeriodicReader by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8960](https://github.com/open-telemetry/opentelemetry-go/pull/8960)
- chore(deps): update ubuntu:26.04 docker digest to [`61ebaa5`](https://github.com/open-telemetry/opentelemetry-go/commit/61ebaa5) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8968](https://github.com/open-telemetry/opentelemetry-go/pull/8968)
- sdk/metric: Retain advisory attributes when View has no AttributeFilter by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8859](https://github.com/open-telemetry/opentelemetry-go/pull/8859)
- chore(deps): update module github.com/ldez/tagliatelle to v0.8.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8970](https://github.com/open-telemetry/opentelemetry-go/pull/8970)
- sdk/metric: Document nil AttributeFilter behavior by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8971](https://github.com/open-telemetry/opentelemetry-go/pull/8971)
- chore: fix gofumpt extra-rules warning by [@&#8203;dmathieu](https://github.com/dmathieu) in [#&#8203;8942](https://github.com/open-telemetry/opentelemetry-go/pull/8942)
- chore(deps): update codecov/codecov-action action to v7.1.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8975](https://github.com/open-telemetry/opentelemetry-go/pull/8975)
- chore(deps): update module github.com/ldez/gomoddirectives to v0.10.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8974](https://github.com/open-telemetry/opentelemetry-go/pull/8974)
- sdk/trace/x: deduplicate tracestate combination error report in ProbabilitySampler by [@&#8203;alex131125](https://github.com/alex131125) in [#&#8203;8969](https://github.com/open-telemetry/opentelemetry-go/pull/8969)
- \[chore]: fix article and word typos in doc comments by [@&#8203;simpleqt](https://github.com/simpleqt) in [#&#8203;8972](https://github.com/open-telemetry/opentelemetry-go/pull/8972)
- \[chore]: fix two doc comments that name the wrong method by [@&#8203;simpleqt](https://github.com/simpleqt) in [#&#8203;8973](https://github.com/open-telemetry/opentelemetry-go/pull/8973)
- Update googleapis to [`eeb232e`](https://github.com/open-telemetry/opentelemetry-go/commit/eeb232e) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8983](https://github.com/open-telemetry/opentelemetry-go/pull/8983)
- sdk/metric: extract generic hotColdMap for delta aggregators by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8756](https://github.com/open-telemetry/opentelemetry-go/pull/8756)
- Update github/codeql-action action to v4.38.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8987](https://github.com/open-telemetry/opentelemetry-go/pull/8987)
- Update googleapis to [`cecb647`](https://github.com/open-telemetry/opentelemetry-go/commit/cecb647) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8986](https://github.com/open-telemetry/opentelemetry-go/pull/8986)
- \[chore]: remove CLAUDE.md in favor of AGENTS.md by [@&#8203;jarrensj](https://github.com/jarrensj) in [#&#8203;8988](https://github.com/open-telemetry/opentelemetry-go/pull/8988)
- test(internal/tools/verifyreadmes): add unit tests for verifyReadme by [@&#8203;marcschaeferger](https://github.com/marcschaeferger) in [#&#8203;8066](https://github.com/open-telemetry/opentelemetry-go/pull/8066)
- perf(trace): unroll hex decoders to drop bounds checks by [@&#8203;itssaharsh](https://github.com/itssaharsh) in [#&#8203;8740](https://github.com/open-telemetry/opentelemetry-go/pull/8740)
- sdk/metric: add canonical attribute identity by [@&#8203;MrAlias](https://github.com/MrAlias) in [#&#8203;8963](https://github.com/open-telemetry/opentelemetry-go/pull/8963)
- attribute: add Set.String by [@&#8203;iyiola-dev](https://github.com/iyiola-dev) in [#&#8203;8347](https://github.com/open-telemetry/opentelemetry-go/pull/8347)
- attribute: add String method for KeyValue by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8205](https://github.com/open-telemetry/opentelemetry-go/pull/8205)
- Update open-telemetry/shared-workflows action to v0.15.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8992](https://github.com/open-telemetry/opentelemetry-go/pull/8992)
- Update googleapis to [`b142276`](https://github.com/open-telemetry/opentelemetry-go/commit/b142276) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8991](https://github.com/open-telemetry/opentelemetry-go/pull/8991)
- Update golang.org/x/telemetry digest to [`bdcd072`](https://github.com/open-telemetry/opentelemetry-go/commit/bdcd072) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8990](https://github.com/open-telemetry/opentelemetry-go/pull/8990)
- Update module google.golang.org/grpc to v1.84.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8982](https://github.com/open-telemetry/opentelemetry-go/pull/8982)
- Update open-telemetry/shared-workflows action to v0.16.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8995](https://github.com/open-telemetry/opentelemetry-go/pull/8995)
- Update github.com/opentracing-contrib/go-grpc/test digest to [`c4b3818`](https://github.com/open-telemetry/opentelemetry-go/commit/c4b3818) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8994](https://github.com/open-telemetry/opentelemetry-go/pull/8994)
- Revert "Update module google.golang.org/grpc to v1.84.0 ([#&#8203;8982](https://github.com/open-telemetry/opentelemetry-go/issues/8982))" by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8997](https://github.com/open-telemetry/opentelemetry-go/pull/8997)
- metricdatatest: use attribute collection formatting by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8993](https://github.com/open-telemetry/opentelemetry-go/pull/8993)
- Update github.com/charmbracelet/ultraviolet digest to [`4e49372`](https://github.com/open-telemetry/opentelemetry-go/commit/4e49372) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8998](https://github.com/open-telemetry/opentelemetry-go/pull/8998)
- fix(sdk/metric): preserve int64 precision in sum and histogram aggregations by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8981](https://github.com/open-telemetry/opentelemetry-go/pull/8981)
- Update golang.org/x/telemetry digest to [`c6096d2`](https://github.com/open-telemetry/opentelemetry-go/commit/c6096d2) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9000](https://github.com/open-telemetry/opentelemetry-go/pull/9000)
- Fix OSS-Fuzz issues in FuzzHashKVs, FuzzGetBin, and FuzzSpanIDFromHex by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8996](https://github.com/open-telemetry/opentelemetry-go/pull/8996)
- Update github.com/timakin/bodyclose digest to [`98ded9a`](https://github.com/open-telemetry/opentelemetry-go/commit/98ded9a) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9003](https://github.com/open-telemetry/opentelemetry-go/pull/9003)
- Update module github.com/ProtonMail/go-crypto to v1.5.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9004](https://github.com/open-telemetry/opentelemetry-go/pull/9004)
- Update module github.com/ProtonMail/go-crypto to v1.5.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9021](https://github.com/open-telemetry/opentelemetry-go/pull/9021)
- Update golang.org/x/telemetry digest to [`9662f6f`](https://github.com/open-telemetry/opentelemetry-go/commit/9662f6f) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9020](https://github.com/open-telemetry/opentelemetry-go/pull/9020)
- Update module github.com/mgechev/revive to v1.17.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9022](https://github.com/open-telemetry/opentelemetry-go/pull/9022)
- Add ps-mir as an approver by [@&#8203;MrAlias](https://github.com/MrAlias) in [#&#8203;9018](https://github.com/open-telemetry/opentelemetry-go/pull/9018)
- Add Marc Schäfer as a project Triager by [@&#8203;MrAlias](https://github.com/MrAlias) in [#&#8203;9019](https://github.com/open-telemetry/opentelemetry-go/pull/9019)
- Update golang.org/x/telemetry digest to [`ed294f9`](https://github.com/open-telemetry/opentelemetry-go/commit/ed294f9) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9027](https://github.com/open-telemetry/opentelemetry-go/pull/9027)
- Update github/codeql-action action to v4.38.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9025](https://github.com/open-telemetry/opentelemetry-go/pull/9025)
- Update module github.com/golangci/golangci-lint/v2 to v2.14.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9026](https://github.com/open-telemetry/opentelemetry-go/pull/9026)
- Update github.com/charmbracelet/ultraviolet digest to [`b78653f`](https://github.com/open-telemetry/opentelemetry-go/commit/b78653f) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9036](https://github.com/open-telemetry/opentelemetry-go/pull/9036)
- Update github.com/charmbracelet/ultraviolet digest to [`bbf040a`](https://github.com/open-telemetry/opentelemetry-go/commit/bbf040a) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9037](https://github.com/open-telemetry/opentelemetry-go/pull/9037)
- Update module github.com/golangci/golines to v0.16.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9043](https://github.com/open-telemetry/opentelemetry-go/pull/9043)
- Update module github.com/prometheus/common to v0.72.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9044](https://github.com/open-telemetry/opentelemetry-go/pull/9044)
- Update module go.opentelemetry.io/collector/pdata to v1.68.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9046](https://github.com/open-telemetry/opentelemetry-go/pull/9046)
- Update module github.com/uudashr/iface to v1.5.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9042](https://github.com/open-telemetry/opentelemetry-go/pull/9042)
- Update module github.com/ProtonMail/go-crypto to v1.5.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9040](https://github.com/open-telemetry/opentelemetry-go/pull/9040)
- Update open-telemetry/shared-workflows action to v0.17.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9047](https://github.com/open-telemetry/opentelemetry-go/pull/9047)
- Update github.com/golangci/canonicalheader digest to [`e18e5c5`](https://github.com/open-telemetry/opentelemetry-go/commit/e18e5c5) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9039](https://github.com/open-telemetry/opentelemetry-go/pull/9039)
- Update googleapis to [`8a89bd6`](https://github.com/open-telemetry/opentelemetry-go/commit/8a89bd6) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9041](https://github.com/open-telemetry/opentelemetry-go/pull/9041)
- Update github.com/charmbracelet/ultraviolet digest to [`666ce5e`](https://github.com/open-telemetry/opentelemetry-go/commit/666ce5e) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9051](https://github.com/open-telemetry/opentelemetry-go/pull/9051)
- Remove retired Go Report Card badge from README by [@&#8203;kijimad](https://github.com/kijimad) in [#&#8203;9050](https://github.com/open-telemetry/opentelemetry-go/pull/9050)
- fix(sdk/metric): Fix data race with initialization of NewPeriodicReader by [@&#8203;CTrando](https://github.com/CTrando) in [#&#8203;9028](https://github.com/open-telemetry/opentelemetry-go/pull/9028)
- Update module github.com/dlclark/regexp2/v2 to v2.8.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9061](https://github.com/open-telemetry/opentelemetry-go/pull/9061)
- Update github.com/charmbracelet/ultraviolet digest to [`270558f`](https://github.com/open-telemetry/opentelemetry-go/commit/270558f) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9060](https://github.com/open-telemetry/opentelemetry-go/pull/9060)
- chore: Replace deprecated gofumpt extra-rules with extra options by [@&#8203;kijimad](https://github.com/kijimad) in [#&#8203;9062](https://github.com/open-telemetry/opentelemetry-go/pull/9062)
- Update module github.com/pjbgf/sha1cd to v0.7.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9064](https://github.com/open-telemetry/opentelemetry-go/pull/9064)
- Update github.com/golangci/rowserrcheck digest to [`f772fe6`](https://github.com/open-telemetry/opentelemetry-go/commit/f772fe6) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9066](https://github.com/open-telemetry/opentelemetry-go/pull/9066)
- Update github.com/charmbracelet/ultraviolet digest to [`8786532`](https://github.com/open-telemetry/opentelemetry-go/commit/8786532) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9065](https://github.com/open-telemetry/opentelemetry-go/pull/9065)
- Fix zero attribute value length for malformed UTF-8 by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;9054](https://github.com/open-telemetry/opentelemetry-go/pull/9054)
- Update module github.com/uudashr/iface to v1.5.3 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9067](https://github.com/open-telemetry/opentelemetry-go/pull/9067)
- Update ubuntu:26.04 Docker digest to [`88a381d`](https://github.com/open-telemetry/opentelemetry-go/commit/88a381d) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;9069](https://github.com/open-telemetry/opentelemetry-go/pull/9069)
- Release 1.47.0/0.69.0/0.23.0/0.1.0 by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;9024](https://github.com/open-telemetry/opentelemetry-go/pull/9024)

##### New Contributors

- [@&#8203;chiliec](https://github.com/chiliec) made their first contribution in [#&#8203;8832](https://github.com/open-telemetry/opentelemetry-go/pull/8832)
- [@&#8203;maithilijoshi20](https://github.com/maithilijoshi20) made their first contribution in [#&#8203;8800](https://github.com/open-telemetry/opentelemetry-go/pull/8800)
- [@&#8203;ps-mir](https://github.com/ps-mir) made their first contribution in [#&#8203;8805](https://github.com/open-telemetry/opentelemetry-go/pull/8805)
- [@&#8203;ShreeyashPatil2708](https://github.com/ShreeyashPatil2708) made their first contribution in [#&#8203;8844](https://github.com/open-telemetry/opentelemetry-go/pull/8844)
- [@&#8203;alex131125](https://github.com/alex131125) made their first contribution in [#&#8203;8969](https://github.com/open-telemetry/opentelemetry-go/pull/8969)
- [@&#8203;simpleqt](https://github.com/simpleqt) made their first contribution in [#&#8203;8972](https://github.com/open-telemetry/opentelemetry-go/pull/8972)
- [@&#8203;jarrensj](https://github.com/jarrensj) made their first contribution in [#&#8203;8988](https://github.com/open-telemetry/opentelemetry-go/pull/8988)
- [@&#8203;iyiola-dev](https://github.com/iyiola-dev) made their first contribution in [#&#8203;8347](https://github.com/open-telemetry/opentelemetry-go/pull/8347)
- [@&#8203;kijimad](https://github.com/kijimad) made their first contribution in [#&#8203;9050](https://github.com/open-telemetry/opentelemetry-go/pull/9050)
- [@&#8203;CTrando](https://github.com/CTrando) made their first contribution in [#&#8203;9028](https://github.com/open-telemetry/opentelemetry-go/pull/9028)

**Full Changelog**: <https://github.com/open-telemetry/opentelemetry-go/compare/v1.47.0-rc.1...v1.47.0>

</details>

<details>
<summary>open-telemetry/opentelemetry-proto-go (go.opentelemetry.io/proto/otlp)</summary>

### [`v1.11.1`](https://github.com/open-telemetry/opentelemetry-proto-go/releases/tag/v1.11.1): /v0.4.1

[Compare Source](https://github.com/open-telemetry/opentelemetry-proto-go/compare/v1.11.0...v1.11.1)

Generated Go code for the [`v1.11.1`][otlp] version of the OTLP.

[otlp]: https://github.com/open-telemetry/opentelemetry-proto/releases/tag/v1.11.1

#### What's Changed

- chore(deps): update module github.com/go-git/go-billy/v5 to v5.9.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;664](https://github.com/open-telemetry/opentelemetry-proto-go/pull/664)
- chore(deps): update ossf/scorecard-action action to v2.4.4 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;663](https://github.com/open-telemetry/opentelemetry-proto-go/pull/663)
- chore(deps): update googleapis to [`3fe39f3`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/3fe39f3) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;662](https://github.com/open-telemetry/opentelemetry-proto-go/pull/662)
- chore(deps): update googleapis to [`b2f2020`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/b2f2020) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;665](https://github.com/open-telemetry/opentelemetry-proto-go/pull/665)
- chore(deps): update module go.yaml.in/yaml/v3 to v3.0.5 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;666](https://github.com/open-telemetry/opentelemetry-proto-go/pull/666)
- chore(deps): update googleapis to [`6c54ddd`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/6c54ddd) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;667](https://github.com/open-telemetry/opentelemetry-proto-go/pull/667)
- chore(deps): update google.golang.org/genproto/googleapis/api digest to [`8efbd57`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/8efbd57) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;670](https://github.com/open-telemetry/opentelemetry-proto-go/pull/670)
- chore(deps): update module github.com/go-git/go-git/v5 to v5.19.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;669](https://github.com/open-telemetry/opentelemetry-proto-go/pull/669)
- chore(deps): update google.golang.org/genproto/googleapis/rpc digest to [`8efbd57`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/8efbd57) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;671](https://github.com/open-telemetry/opentelemetry-proto-go/pull/671)
- chore(deps): update github/codeql-action action to v4.37.4 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;673](https://github.com/open-telemetry/opentelemetry-proto-go/pull/673)
- fix(deps): update module google.golang.org/grpc to v1.83.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;672](https://github.com/open-telemetry/opentelemetry-proto-go/pull/672)
- chore: Change Renovate configuration to best practices by [@&#8203;thompson-tomo](https://github.com/thompson-tomo) in [#&#8203;674](https://github.com/open-telemetry/opentelemetry-proto-go/pull/674)
- chore(deps): pin dependencies by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;677](https://github.com/open-telemetry/opentelemetry-proto-go/pull/677)
- chore(deps): update github/codeql-action action to v4.37.5 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;676](https://github.com/open-telemetry/opentelemetry-proto-go/pull/676)
- chore(deps): update googleapis to [`6ac0973`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/6ac0973) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;678](https://github.com/open-telemetry/opentelemetry-proto-go/pull/678)
- chore(deps): update github/codeql-action action to v4.37.6 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;679](https://github.com/open-telemetry/opentelemetry-proto-go/pull/679)
- ci: add shared security scanning workflow by [@&#8203;opentelemetrybot](https://github.com/opentelemetrybot) in [#&#8203;675](https://github.com/open-telemetry/opentelemetry-proto-go/pull/675)
- chore(deps): update open-telemetry/shared-workflows action to v0.5.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;682](https://github.com/open-telemetry/opentelemetry-proto-go/pull/682)
- chore(deps): update module github.com/cloudflare/circl to v1.6.5 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;681](https://github.com/open-telemetry/opentelemetry-proto-go/pull/681)
- fix(deps): update module github.com/grpc-ecosystem/grpc-gateway/v2 to v2.30.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;680](https://github.com/open-telemetry/opentelemetry-proto-go/pull/680)
- chore(deps): update googleapis to [`c8921c7`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/c8921c7) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;684](https://github.com/open-telemetry/opentelemetry-proto-go/pull/684)
- chore(deps): update open-telemetry/shared-workflows action to v0.6.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;683](https://github.com/open-telemetry/opentelemetry-proto-go/pull/683)
- chore(deps): update googleapis to [`ec0a776`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/ec0a776) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;685](https://github.com/open-telemetry/opentelemetry-proto-go/pull/685)
- fix(deps): update module google.golang.org/protobuf to v1.36.12 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;686](https://github.com/open-telemetry/opentelemetry-proto-go/pull/686)
- chore(deps): update module golang.org/x/mod to v0.39.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;687](https://github.com/open-telemetry/opentelemetry-proto-go/pull/687)
- ci: use shared OSSF Scorecard workflow by [@&#8203;opentelemetrybot](https://github.com/opentelemetrybot) in [#&#8203;688](https://github.com/open-telemetry/opentelemetry-proto-go/pull/688)
- chore(deps): update all golang.org/x packages by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;689](https://github.com/open-telemetry/opentelemetry-proto-go/pull/689)
- chore(deps): update otel/build-protobuf docker tag to v0.26.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;668](https://github.com/open-telemetry/opentelemetry-proto-go/pull/668)
- chore(deps): update module golang.org/x/mod to v0.40.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;690](https://github.com/open-telemetry/opentelemetry-proto-go/pull/690)
- chore(deps): update open-telemetry/shared-workflows action to v0.7.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;691](https://github.com/open-telemetry/opentelemetry-proto-go/pull/691)
- chore(deps): update googleapis to [`ac3dfec`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/ac3dfec) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;692](https://github.com/open-telemetry/opentelemetry-proto-go/pull/692)
- chore(deps): update googleapis to [`1b09341`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/1b09341) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;693](https://github.com/open-telemetry/opentelemetry-proto-go/pull/693)
- fix(deps): update module google.golang.org/grpc to v1.83.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;694](https://github.com/open-telemetry/opentelemetry-proto-go/pull/694)
- chore(deps): update googleapis to [`08b0e42`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/08b0e42) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;695](https://github.com/open-telemetry/opentelemetry-proto-go/pull/695)
- chore(deps): update open-telemetry/shared-workflows action to v0.8.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;696](https://github.com/open-telemetry/opentelemetry-proto-go/pull/696)
- chore(deps): update open-telemetry/shared-workflows action to v0.10.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;697](https://github.com/open-telemetry/opentelemetry-proto-go/pull/697)
- chore(deps): update open-telemetry/shared-workflows action to v0.11.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;698](https://github.com/open-telemetry/opentelemetry-proto-go/pull/698)
- chore(deps): update googleapis to [`da73d73`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/da73d73) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;700](https://github.com/open-telemetry/opentelemetry-proto-go/pull/700)
- fix(deps): update module google.golang.org/grpc to v1.83.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;699](https://github.com/open-telemetry/opentelemetry-proto-go/pull/699)
- chore(deps): update module github.com/skeema/knownhosts to v1.3.3 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;702](https://github.com/open-telemetry/opentelemetry-proto-go/pull/702)
- chore(deps): update open-telemetry/shared-workflows action to v0.12.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;701](https://github.com/open-telemetry/opentelemetry-proto-go/pull/701)
- chore(deps): update googleapis to [`18b4a75`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/18b4a75) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;703](https://github.com/open-telemetry/opentelemetry-proto-go/pull/703)
- chore(deps): update open-telemetry/shared-workflows action to v0.13.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;704](https://github.com/open-telemetry/opentelemetry-proto-go/pull/704)
- chore(deps): update googleapis to [`f8649dd`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/f8649dd) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;706](https://github.com/open-telemetry/opentelemetry-proto-go/pull/706)
- chore(deps): update googleapis to [`f61a6ca`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/f61a6ca) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;708](https://github.com/open-telemetry/opentelemetry-proto-go/pull/708)
- Make slim OTLP descriptors coexist with canonical OTLP by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;707](https://github.com/open-telemetry/opentelemetry-proto-go/pull/707)
- chore(deps): update open-telemetry/shared-workflows action to v0.14.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;710](https://github.com/open-telemetry/opentelemetry-proto-go/pull/710)
- Drop support for Go 1.25 by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;711](https://github.com/open-telemetry/opentelemetry-proto-go/pull/711)
- chore(deps): update all golang.org/x packages by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;709](https://github.com/open-telemetry/opentelemetry-proto-go/pull/709)
- chore(deps): update googleapis to [`eeb232e`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/eeb232e) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;713](https://github.com/open-telemetry/opentelemetry-proto-go/pull/713)
- fix(deps): update module google.golang.org/grpc to v1.84.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;712](https://github.com/open-telemetry/opentelemetry-proto-go/pull/712)
- chore(deps): update googleapis to [`cecb647`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/cecb647) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;714](https://github.com/open-telemetry/opentelemetry-proto-go/pull/714)
- chore(deps): update googleapis to [`b142276`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/b142276) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;715](https://github.com/open-telemetry/opentelemetry-proto-go/pull/715)
- chore(deps): update open-telemetry/shared-workflows action to v0.15.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;716](https://github.com/open-telemetry/opentelemetry-proto-go/pull/716)
- chore(deps): update open-telemetry/shared-workflows action to v0.16.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;717](https://github.com/open-telemetry/opentelemetry-proto-go/pull/717)
- chore(deps): update module github.com/protonmail/go-crypto to v1.5.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;718](https://github.com/open-telemetry/opentelemetry-proto-go/pull/718)
- chore(deps): update module github.com/protonmail/go-crypto to v1.5.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;719](https://github.com/open-telemetry/opentelemetry-proto-go/pull/719)
- fix(deps): update module github.com/grpc-ecosystem/grpc-gateway/v2 to v2.31.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;720](https://github.com/open-telemetry/opentelemetry-proto-go/pull/720)
- chore(deps): update googleapis to [`8a89bd6`](https://github.com/open-telemetry/opentelemetry-proto-go/commit/8a89bd6) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;723](https://github.com/open-telemetry/opentelemetry-proto-go/pull/723)
- chore(deps): update open-telemetry/shared-workflows action to v0.17.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;722](https://github.com/open-telemetry/opentelemetry-proto-go/pull/722)
- chore(deps): update module github.com/protonmail/go-crypto to v1.5.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;721](https://github.com/open-telemetry/opentelemetry-proto-go/pull/721)
- Release v1.11.1 by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;724](https://github.com/open-telemetry/opentelemetry-proto-go/pull/724)

#### New Contributors

- [@&#8203;thompson-tomo](https://github.com/thompson-tomo) made their first contribution in [#&#8203;674](https://github.com/open-telemetry/opentelemetry-proto-go/pull/674)

**Full Changelog**: <https://github.com/open-telemetry/opentelemetry-proto-go/compare/v1.11.0...v1.11.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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-->

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1308
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-10-12 15:36:28 +00:00
Lunny Xiaoandbircni 49e34813ae docs: describe the logging package (#1305)
Drop the logrus references from the docs and the config example, and add a Logging section to `DEVELOPMENT.md` on how to log and test logging.

Stacked on https://gitea.com/gitea/runner/pulls/1304.

Assisted-by: Codet
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1305
Reviewed-by: bircni <[email protected]>
2026-10-12 06:52:14 +00:00
Lunny Xiaoandbircni e0ef637805 refactor(logging): replace the logrus backend with log/slog (#1304)
Log through `log/slog`, with a console `slog.Handler` that writes the text the runner has always written (sorted `key=value` fields on a plain stream, a colored prefix on a terminal), so nothing changes for users. I compared its output byte for byte with logrus's `TextFormatter` over plain, colored, timestamped and caller cases.

Why not `slog.TextHandler`: its output differs from what users and their log parsers see today, and `ReplaceAttr` cannot close the gap.
- level is `WARN` where logrus wrote `warning`, and time has milliseconds and no quotes
- fields keep call order instead of being sorted
- the caller is one `source=` key, where the runner writes `func=` and `file=` (reshaped by `callPrettyfier`), and `ReplaceAttr` returns a single attribute
- no colored prefix on a terminal
- no hook to rewrite a whole formatted line, which the process-wide secret masking relies on

Libraries that log through logrus's global logger (the workflow model, the Docker client) are forwarded to the process-wide logger, so they keep following its level, format and secret masking. This is why logrus stays in `go.mod`.

Stacked on https://gitea.com/gitea/runner/pulls/1303.

Assisted-by: Codet
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1304
Reviewed-by: bircni <[email protected]>
2026-10-12 06:36:55 +00:00
Lunny Xiaoandbircni 8502663d0e chore(lint): forbid importing logrus outside the logging package (#1303)
Add a `depguard` rule so everything logs through `act/common/logging`, which keeps the backend swappable.

Stacked on https://gitea.com/gitea/runner/pulls/1302.

Assisted-by: Codet
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1303
Reviewed-by: Zettat123 <[email protected]>
2026-10-12 03:39:48 +00:00
Lunny Xiaoandbircni 4e8037a93b refactor(logging): use the logging package in internal (#1302)
Move the log calls in `internal` onto `logging`, and add what the process-wide logger setup and its tests need: `SetTextFormat` with a line filter for secret masking (replacing `report.MaskingFormatter`), `SetReportCaller`, `SetDefault` and `logtest.SetDefault`. After this only the `logging` package imports logrus.

Stacked on https://gitea.com/gitea/runner/pulls/1301.

Assisted-by: Codet
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1302
Reviewed-by: bircni <[email protected]>
2026-10-12 00:19:01 +00:00
Lunny Xiaoandbircni 76110557ab refactor(logging): use the logging package for the remaining act log calls (#1301)
Move the global logrus calls left in `act` onto `logging`, and add `SetOutput` and `Output` for tests that capture the process logger. Behavior is unchanged.

Stacked on https://gitea.com/gitea/runner/pulls/1300.

Assisted-by: Codet
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1301
Reviewed-by: bircni <[email protected]>
2026-10-11 23:55:02 +00:00
Lunny Xiaoandbircni 3bc5cc03f8 test(logging): add logtest.NewLogger (#1300)
Return a `Logger` and its recording `Handler` together, so tests no longer need the two-line setup.

Stacked on https://gitea.com/gitea/runner/pulls/1295.

Assisted-by: Codet
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1300
Reviewed-by: bircni <[email protected]>
2026-10-11 23:47:50 +00:00
Lunny Xiaoandbircni de961b91eb refactor(logging): add a logging abstraction and move the job logger onto it (#1295)
Add `act/common/logging`: a printf-style `Logger` on a slog-shaped `Handler`, with a logrus-backed handler as the only backend, plus a `logtest` handler for tests. Move the job logger onto it: `common.Logger` returns `*logging.Logger`, the job log formatters and the `Reporter` hook become handlers, and composite step IDs travel in the context.

Logging is tightly coupled to logrus types (hooks, formatters, `Entry.Data`). Going through our own interface first lets the remaining call sites move over with unchanged behavior, and lets the backend be swapped for `log/slog` in one place afterwards. The global process logger and its masking formatter stay on logrus for now.

Assisted-by: Codet
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1295
Reviewed-by: bircni <[email protected]>
2026-10-11 23:26:00 +00:00
silverwind ad9eeb668a fix: job container, daemon startup, action cache and exec fixes (#1294)
- Keep `GITHUB_ENV`, `GITHUB_OUTPUT`, `GITHUB_STATE` and `GITHUB_PATH` when a job times out or is cancelled, so post steps still get their state
- Retry the startup Declare while Gitea is unreachable instead of exiting, still failing fast on rejected registrations, auth and misconfiguration
- Reuse a local image when a platform variant such as `linux/arm/v7` is requested instead of pulling again
- Read `PATH` from the created job container so cross-architecture jobs get tool paths for the right architecture
- Extract action copies relative to the act volume so they never cross an image's `/var/run` symlink, which rootless Podman rejects
- Keep the job container's entrypoint when container options set `--entrypoint`, warning instead of appending it to the keep-alive command
- Lock action cache entries across runner processes that share a cache directory
- `exec`: omit `GITHUB_SERVER_URL` and `GITHUB_API_URL` without `--gitea-instance` instead of exporting a bare `https://`, and read owner and repository from any origin remote

Reviewed-on: https://gitea.com/gitea/runner/pulls/1294
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-10 18:11:03 +00:00
Renovate Botandsilverwind b741335222 fix(deps): update module golang.org/x/net to v0.60.0 [security] (#1292)
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/net](https://pkg.go.dev/golang.org/x/net) | [`v0.59.0` → `v0.60.0`](https://cs.opensource.google/go/x/net/+/refs/tags/v0.59.0...refs/tags/v0.60.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fnet/v0.60.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fnet/v0.59.0/v0.60.0?slim=true) |

---

### HTTP/2 server memory exhaustion due to Trailer headers in net/http
[CVE-2026-78659](https://nvd.nist.gov/vuln/detail/CVE-2026-78659) / [GO-2026-6603](https://pkg.go.dev/vuln/GO-2026-6603)

<details>
<summary>More information</summary>

#### Details
When "Trailer" headers are sent by a client, the HTTP server internally uses the header values to populate the Request.Trailer map passed to the server handler. Because Request.Trailer is a map, each entry incurs memory overhead. For HTTP/2 servers, a malicious client can exploit this by sending a "Trailer" header that declares a large number of fields, causing the server to allocate a disproportionate amount of memory while bypassing Server.MaxHeaderValueCount and Server.MaxHeaderBytes limits. This exploit is not applicable for HTTP/1 servers, which do not support multiplexing a large number of requests over one TCP connection, and whose Server.MaxHeaderBytes are calculated differently.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847185](https://go.dev/cl/847185)
- [https://go.dev/cl/847314](https://go.dev/cl/847314)
- [https://go.dev/issue/81857](https://go.dev/issue/81857)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)
- [https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs](https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6603) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### HTTP/2 transport accepts malformed framing-related headers in net/http
[CVE-2026-78660](https://nvd.nist.gov/vuln/detail/CVE-2026-78660) / [GO-2026-6610](https://pkg.go.dev/vuln/GO-2026-6610)

<details>
<summary>More information</summary>

#### Details
Historically, we have been rather lax about malformed framing-related headers in our HTTP/2 implementation, as they cannot interfere with HTTP/2 framing. However, this makes it possible for our HTTP/2 implementation to forward responses containing such headers to an HTTP/1 client when acting as a reverse proxy. If the HTTP/1 client also does not behave strictly enough, this can result in response smuggling.

#### Severity
Unknown

#### References
- [https://go.dev/cl/835145](https://go.dev/cl/835145)
- [https://go.dev/cl/836385](https://go.dev/cl/836385)
- [https://go.dev/issue/81115](https://go.dev/issue/81115)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6610) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Excessive CPU consumption from repeated initial window changes in net/http
[CVE-2026-78669](https://nvd.nist.gov/vuln/detail/CVE-2026-78669) / [GO-2026-6611](https://pkg.go.dev/vuln/GO-2026-6611)

<details>
<summary>More information</summary>

#### Details
A malicious HTTP/2 peer can cause excessive CPU consumption in the client or server by opening a large number of streams and then sending many small SETTINGS frames containing SETTINGS_INITIAL_WINDOW_SIZE values.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847186](https://go.dev/cl/847186)
- [https://go.dev/cl/847308](https://go.dev/cl/847308)
- [https://go.dev/issue/81742](https://go.dev/issue/81742)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)
- [https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs](https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6611) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Double flow control refund on HTTP/2 server streams in net/http
[CVE-2026-78663](https://nvd.nist.gov/vuln/detail/CVE-2026-78663) / [GO-2026-6612](https://pkg.go.dev/vuln/GO-2026-6612)

<details>
<summary>More information</summary>

#### Details
The HTTP/2 server can refund connection-level flow control twice for the same data: Once when a client resets a stream (refunding data for any sent-but-unread portion of the stream), and again when a request handler reads the buffered data. A malicious client can exploit this to bypass the configured connection-level flow control limit (MaxReceiveBufferPerConnection). Total buffered data is still limited by the concurrent stream limit and stream-level flow control.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847187](https://go.dev/cl/847187)
- [https://go.dev/cl/847310](https://go.dev/cl/847310)
- [https://go.dev/issue/81743](https://go.dev/issue/81743)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)
- [https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs](https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6612) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### HTTP/2 server crash due to HPACK encoder race in net/http
[CVE-2026-97032](https://nvd.nist.gov/vuln/detail/CVE-2026-97032) / [GO-2026-6617](https://pkg.go.dev/vuln/GO-2026-6617)

<details>
<summary>More information</summary>

#### Details
HTTP/2 servers could end up crashing due to inadvertently modifying its HPACK encoder concurrently. This happens because the server modifies the HPACK encoder from two goroutines without synchronization: one uses the encoder to encode a HEADERS frame as part of a response sent to a client and the other modifies the encoder's table size when handling a SETTINGS frame containing SETTINGS_HEADER_TABLE_SIZE that a client sends. A malicious client can repeatedly send a request while changing the header table size to crash the server.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847188](https://go.dev/cl/847188)
- [https://go.dev/cl/847313](https://go.dev/cl/847313)
- [https://go.dev/issue/81867](https://go.dev/issue/81867)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)
- [https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs](https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6617) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- 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-->

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1292
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-10-10 03:45:46 +00:00
Renovate Bot 20143c9558 fix(deps): update go toolchain directive to v1.27.2 [security] (#1291)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [go](https://go.dev/) ([source](https://github.com/golang/go)) | toolchain | patch | `1.27.1` → `1.27.2` |

---

### Reset context tracking on consecutive template expressions in html/template
[CVE-2026-94448](https://nvd.nist.gov/vuln/detail/CVE-2026-94448) / [GO-2026-6599](https://pkg.go.dev/vuln/GO-2026-6599)

<details>
<summary>More information</summary>

#### Details
When a JavaScript template literal contains consecutive expressions, the context tracking state was not properly reset upon entering a new expression.

We now ensure that template-literal expression entries correctly reset context variables so all subsequent regular expression literals are accurately recognized and escaped.

#### Severity
Unknown

#### References
- [https://go.dev/cl/839866](https://go.dev/cl/839866)
- [https://go.dev/issue/81821](https://go.dev/issue/81821)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6599) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Recognize yield as regexp preceder keyword in html/template
[CVE-2026-97030](https://nvd.nist.gov/vuln/detail/CVE-2026-97030) / [GO-2026-6600](https://pkg.go.dev/vuln/GO-2026-6600)

<details>
<summary>More information</summary>

#### Details
A trusted template author may have previously written a valid template wherein the use of the 'yield' keyword would not be correctly escaped.

We now ensure that valid keyword uses are escaped and non-keyword uses are not escaped.

#### Severity
Unknown

#### References
- [https://go.dev/cl/840925](https://go.dev/cl/840925)
- [https://go.dev/issue/81823](https://go.dev/issue/81823)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6600) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### HTTP/2 server memory exhaustion due to Trailer headers in net/http
[CVE-2026-78659](https://nvd.nist.gov/vuln/detail/CVE-2026-78659) / [GO-2026-6603](https://pkg.go.dev/vuln/GO-2026-6603)

<details>
<summary>More information</summary>

#### Details
When "Trailer" headers are sent by a client, the HTTP server internally uses the header values to populate the Request.Trailer map passed to the server handler. Because Request.Trailer is a map, each entry incurs memory overhead. For HTTP/2 servers, a malicious client can exploit this by sending a "Trailer" header that declares a large number of fields, causing the server to allocate a disproportionate amount of memory while bypassing Server.MaxHeaderValueCount and Server.MaxHeaderBytes limits. This exploit is not applicable for HTTP/1 servers, which do not support multiplexing a large number of requests over one TCP connection, and whose Server.MaxHeaderBytes are calculated differently.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847185](https://go.dev/cl/847185)
- [https://go.dev/cl/847314](https://go.dev/cl/847314)
- [https://go.dev/issue/81857](https://go.dev/issue/81857)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)
- [https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs](https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6603) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Root.Mkdir(All) can follow junctions out of the root on Windows in os
[CVE-2026-56857](https://nvd.nist.gov/vuln/detail/CVE-2026-56857) / [GO-2026-6604](https://pkg.go.dev/vuln/GO-2026-6604)

<details>
<summary>More information</summary>

#### Details
On Windows, when the target of Root.Mkdir or Root.MkdirAll is a junction pointing to an empty location, the operation can create a directory at the junction target even when that target is located outside the root. This only applies to operations where the last path component is a junction (path/to/junction, but not path/junction/target).

#### Severity
Unknown

#### References
- [https://go.dev/cl/847305](https://go.dev/cl/847305)
- [https://go.dev/issue/81739](https://go.dev/issue/81739)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6604) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### HTTP/1 client connection desynchronization after CONNECT rejection in net/http
[CVE-2026-56866](https://nvd.nist.gov/vuln/detail/CVE-2026-56866) / [GO-2026-6605](https://pkg.go.dev/vuln/GO-2026-6605)

<details>
<summary>More information</summary>

#### Details
When http.Transport sends an HTTP/1 CONNECT request with a non-empty Request.Body, it writes the body directly to the connection without framing after the request headers. If the server rejects the CONNECT request with a non-2xx keep-alive response, Transport returns the connection to the idle pool. Because CONNECT requests do not have a request body, the server may interpret the trailing body bytes as a subsequent pipelined HTTP/1.1 request on the connection, leaving the pooled connection desynchronized and causing the next caller that reuses it to read the response to the injected request. In reverse proxies (including httputil.ReverseProxy) that forward CONNECT requests through a shared Transport, this can lead to cross-user response poisoning.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847306](https://go.dev/cl/847306)
- [https://go.dev/issue/81740](https://go.dev/issue/81740)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6605) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Reject malformed ECH outer extension references in crypto/tls
[CVE-2026-97031](https://nvd.nist.gov/vuln/detail/CVE-2026-97031) / [GO-2026-6607](https://pkg.go.dev/vuln/GO-2026-6607)

<details>
<summary>More information</summary>

#### Details
Multiple ECH outer extension references are not permitted under RFC 9849; previously, a client could send a well-crafted packet that could trigger memory exhaustion in the server process by specifying multiple references.

We now reject these as malformed and curb the memory amplification vector as a result.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847312](https://go.dev/cl/847312)
- [https://go.dev/issue/81855](https://go.dev/issue/81855)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6607) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Memory limit bypass when parsing MIME headers in net/textproto, mime/multipart
[CVE-2026-94440](https://nvd.nist.gov/vuln/detail/CVE-2026-94440) / [GO-2026-6608](https://pkg.go.dev/vuln/GO-2026-6608)

<details>
<summary>More information</summary>

#### Details
Parsing a multipart form can bypass memory limits and read an arbitrarily long line into memory when the remaining limit at the start of a part is less than 400 bytes.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847307](https://go.dev/cl/847307)
- [https://go.dev/issue/81741](https://go.dev/issue/81741)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6608) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Lack of limit on size of parsed Range headers in net/http
[CVE-2026-78667](https://nvd.nist.gov/vuln/detail/CVE-2026-78667) / [GO-2026-6609](https://pkg.go.dev/vuln/GO-2026-6609)

<details>
<summary>More information</summary>

#### Details
When parsing a Range header containing a large number of small ranges, FileServer(FS), ServeContent, and ServeFile(FS) can consume an excessive amount of CPU.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847309](https://go.dev/cl/847309)
- [https://go.dev/issue/81858](https://go.dev/issue/81858)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6609) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### HTTP/2 transport accepts malformed framing-related headers in net/http
[CVE-2026-78660](https://nvd.nist.gov/vuln/detail/CVE-2026-78660) / [GO-2026-6610](https://pkg.go.dev/vuln/GO-2026-6610)

<details>
<summary>More information</summary>

#### Details
Historically, we have been rather lax about malformed framing-related headers in our HTTP/2 implementation, as they cannot interfere with HTTP/2 framing. However, this makes it possible for our HTTP/2 implementation to forward responses containing such headers to an HTTP/1 client when acting as a reverse proxy. If the HTTP/1 client also does not behave strictly enough, this can result in response smuggling.

#### Severity
Unknown

#### References
- [https://go.dev/cl/835145](https://go.dev/cl/835145)
- [https://go.dev/cl/836385](https://go.dev/cl/836385)
- [https://go.dev/issue/81115](https://go.dev/issue/81115)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6610) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Excessive CPU consumption from repeated initial window changes in net/http
[CVE-2026-78669](https://nvd.nist.gov/vuln/detail/CVE-2026-78669) / [GO-2026-6611](https://pkg.go.dev/vuln/GO-2026-6611)

<details>
<summary>More information</summary>

#### Details
A malicious HTTP/2 peer can cause excessive CPU consumption in the client or server by opening a large number of streams and then sending many small SETTINGS frames containing SETTINGS_INITIAL_WINDOW_SIZE values.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847186](https://go.dev/cl/847186)
- [https://go.dev/cl/847308](https://go.dev/cl/847308)
- [https://go.dev/issue/81742](https://go.dev/issue/81742)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)
- [https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs](https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6611) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Double flow control refund on HTTP/2 server streams in net/http
[CVE-2026-78663](https://nvd.nist.gov/vuln/detail/CVE-2026-78663) / [GO-2026-6612](https://pkg.go.dev/vuln/GO-2026-6612)

<details>
<summary>More information</summary>

#### Details
The HTTP/2 server can refund connection-level flow control twice for the same data: Once when a client resets a stream (refunding data for any sent-but-unread portion of the stream), and again when a request handler reads the buffered data. A malicious client can exploit this to bypass the configured connection-level flow control limit (MaxReceiveBufferPerConnection). Total buffered data is still limited by the concurrent stream limit and stream-level flow control.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847187](https://go.dev/cl/847187)
- [https://go.dev/cl/847310](https://go.dev/cl/847310)
- [https://go.dev/issue/81743](https://go.dev/issue/81743)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)
- [https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs](https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6612) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### HTTP/1 server connection desynchronization after 2xx CONNECT response in net/http
[CVE-2026-94439](https://nvd.nist.gov/vuln/detail/CVE-2026-94439) / [GO-2026-6613](https://pkg.go.dev/vuln/GO-2026-6613)

<details>
<summary>More information</summary>

#### Details
When an HTTP server handler sends a 2xx response to an HTTP/1 CONNECT request and returns without hijacking the connection, the server improperly continues to read and serve requests from the connection. Since a 2xx response to an HTTP/1 CONNECT converts the connection into a tunnel, the server should not treat the connection as continuing to contain HTTP.

The impact of this misbehavior is mostly limited to potential request smuggling, where an intermediate proxy considers the data on the connection to be tunneled and the server considers it to be HTTP.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847311](https://go.dev/cl/847311)
- [https://go.dev/issue/81744](https://go.dev/issue/81744)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6613) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### HTTP/2 server crash due to HPACK encoder race in net/http
[CVE-2026-97032](https://nvd.nist.gov/vuln/detail/CVE-2026-97032) / [GO-2026-6617](https://pkg.go.dev/vuln/GO-2026-6617)

<details>
<summary>More information</summary>

#### Details
HTTP/2 servers could end up crashing due to inadvertently modifying its HPACK encoder concurrently. This happens because the server modifies the HPACK encoder from two goroutines without synchronization: one uses the encoder to encode a HEADERS frame as part of a response sent to a client and the other modifies the encoder's table size when handling a SETTINGS frame containing SETTINGS_HEADER_TABLE_SIZE that a client sends. A malicious client can repeatedly send a request while changing the header table size to crash the server.

#### Severity
Unknown

#### References
- [https://go.dev/cl/847188](https://go.dev/cl/847188)
- [https://go.dev/cl/847313](https://go.dev/cl/847313)
- [https://go.dev/issue/81867](https://go.dev/issue/81867)
- [https://groups.google.com/g/golang-announce/c/U2fTuyDJznI](https://groups.google.com/g/golang-announce/c/U2fTuyDJznI)
- [https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs](https://groups.google.com/g/golang-announce/c/ZPwCyRUuGBs)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6617) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- 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/1291
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-10-10 03:29:46 +00:00
8b14a38d4a enhance!: send OTLP metrics under OpenTelemetry semantic conventions, add runner state (#1282)
Sends the OTLP metrics under [OpenTelemetry semantic convention](https://opentelemetry.io/docs/specs/semconv/) names where the runner's data matches the definition, like `cicd.worker.count`, `cicd.system.errors` and the `go.*` and `process.*` runtime metrics, and under `gitea.runner.*` otherwise. OTel dashboards then work without remapping, as they already do for the traces from https://gitea.com/gitea/runner/pulls/1206.

Adds a `gitea_runner_state` gauge (`busy`, `idle`, `unavailable`), and `/readyz` now fails once the runner shuts down.

**Breaking**

1. OTLP metric names, units, types and attribute values change, and the info and start time metrics move to resource attributes.
1. Prometheus `gitea_runner_client_errors_total` gains a `code` label, and `gitea_runner_job_total` counts jobs stopped by `runner.timeout` as `timeout` instead of `failure`.

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1282
Reviewed-by: silverwind <[email protected]>
Co-authored-by: wmTJc9IK0Q <[email protected]>
2026-10-08 18:01:36 +00:00
bircniandsilverwind 179e435cd0 enhance(config): add container.sweep option (#1275)
The Docker proxy labels every container, network and volume a job creates and removes them when the job ends, which breaks jobs that deploy long-running services to the runner's own Docker daemon. Document that behavior and add `container.sweep` (default `true`) so operators who deploy to the same daemon can opt out. Kept resources stay labeled with `com.gitea.runner.job`.

Fixes: https://gitea.com/gitea/runner/issues/1243
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1275
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-08 17:53:10 +00:00
f963e9cfe8 enhance: support client certificates for mutual TLS (#1273)
Add `runner.client_cert_file` and `runner.client_key_file` for Gitea instances that require mutual TLS. The runner presents the certificate on its RPCs, job summary uploads, and the action, reusable workflow and `builtin:checkout` clones from the instance. The files are read on use, so renewed certificates apply without a restart. Job steps do not get them. An incomplete or mismatched pair fails startup.

Clones now decide the token, client certificate and `runner.insecure` with one host check, which also gives reusable workflow clones the TLS settings they lacked. `runner.insecure` still only covers the registered address.

Fixes: https://gitea.com/gitea/runner/issues/321
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1273
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-08 17:48:31 +00:00
e5aa9add02 fix: apply the daemon's mtu to job networks (#1270)
Copy the `mtu` of Docker's default bridge network to job networks, since Docker applies the daemon's `mtu` setting only to the default bridge. Docker's default of 1500 is not copied, so `default-network-opts` in the daemon config keeps applying.

Closes https://gitea.com/gitea/runner/issues/232

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1270
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-08 17:39:54 +00:00
329115b148 enhance(config): expand environment values (#1274)
Config values can reference the runner's environment as `${NAME}`, for example `capacity: ${RUNNER_CAPACITY}`, so container deployments can configure the runner without templating the file. An unset `NAME` fails the load instead of silently becoming empty, and `$${NAME}` writes a literal `${NAME}`. Keys are never expanded.

Breaking: an existing value containing `${NAME}`, such as a shell command in a Kubernetes `pod_template`, now needs `$${NAME}`.

Closes https://gitea.com/gitea/runner/issues/551
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1274
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-08 17:31:00 +00:00
bircniandsilverwind ff30f39b1b enhance(runner): add extra HTTP headers (#1269)
Add `runner.extra_headers` for reverse proxies that require request headers on the runner's API requests to Gitea, like registration, polling and log and status reports.

Headers the runner sets itself (authentication, User-Agent, protocol headers) stay authoritative. Extra headers are dropped when a redirect changes the hostname or leaves HTTPS, while the runner's own authentication headers follow redirects as before. Invalid or case-insensitively duplicated header names are rejected at config load.

Fixes: https://gitea.com/gitea/runner/issues/171
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1269
Co-authored-by: bircni <[email protected]>
2026-10-08 17:22:37 +00:00
bircniandsilverwind 3802feb3fe fix: enforce read-only volume restrictions (#1272)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1272
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-08 17:15:41 +00:00
27e9b4de87 fix!: use the git CLI for action clones and make builtin:checkout match actions/checkout (#1277)
The runner now uses the `git` command line instead of go-git to download actions and reusable workflows. go-git had recurring bugs and does not support SHA-256 repositories, and full clones of actions are several times faster.

`builtin:checkout` now runs `git` where the job runs and behaves like `actions/checkout`:

1. A branch is checked out as a local branch tracking `origin`, tag and pull request runs keep their ref, and a triggering tag that moved fails the step.
1. Later steps can fetch and push with the persisted token or SSH key.
1. It supports the `ssh-*`, `lfs`, `submodules`, `sparse-checkout`, `filter`, `fetch-tags`, `clean`, `persist-credentials`, `show-progress` and `set-safe-directory` inputs and the `ref` and `commit` outputs.
1. SHA-256 repositories work for actions, reusable workflows and `builtin:checkout`.

Git keeps the token off its command line, ignores `GIT_DIR` and similar variables inherited by the runner, and keeps action files LF regardless of `core.autocrlf`. A cancelled job lets git remove its lock files.

This is a breaking change: the runner needs Git 2.34.1 or newer on its `PATH`, and so do job images that use `builtin:checkout`. The official runner images include it.

Fixes: https://gitea.com/gitea/runner/issues/1077
Fixes: https://gitea.com/gitea/runner/issues/1267
Fixes: https://gitea.com/gitea/runner/issues/1279
Fixes: https://gitea.com/gitea/runner/issues/1284
Fixes: https://gitea.com/gitea/runner/issues/1285
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1277
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-08 17:08:29 +00:00
bircniandsilverwind 4bedadef9d fix: reject workflows with jobs lacking runs-on (#1271)
Fail the workflow before any of its jobs run when a regular job omits `runs-on` or leaves it empty, as GitHub does, instead of running the job in the fallback image. Gitea rejects such workflows itself since https://github.com/go-gitea/gitea/pull/39480. This covers released Gitea versions, which hand a job without labels to any runner, and `exec`. Reusable workflow jobs and the stepless placeholders the runner builds for a job's `needs` are exempt.

Closes https://gitea.com/gitea/runner/issues/836

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1271
Reviewed-by: silverwind <[email protected]>
2026-10-08 09:48:07 +00:00
83b63250a3 fix: strip log-driver and OOM options when privileged is off (#1283)
When privileged mode is disabled, reset `--log-driver`, `--log-opt`, `--oom-score-adj` and `--oom-kill-disable` from workflow `container.options`. Log drivers run in the host daemon, so a workflow could make it connect to any address, and `--oom-score-adj -1000` exempts a job from the host OOM killer.

Breaking: on unprivileged runners, workflows can no longer set these options, e.g. `--log-driver none` or `--log-opt max-size=10m`. They are dropped with a warning and the job still runs. Runner-level `container.options` and privileged runners are unaffected.

Fixes: https://github.com/go-gitea/gitea/security/advisories/GHSA-cgx2-cw7h-xgcx
Fixes: https://github.com/go-gitea/gitea/security/advisories/GHSA-c988-q5xx-2cff
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1283
Reviewed-by: silverwind <[email protected]>
2026-10-07 19:04:04 +00:00
silverwind 2e59141ae9 fix: allow tmpfs mounts in container options (#1289)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-06 19:19:51 +00:00
020f0d2861 fix: extract copied directories at their destination in Kubernetes pods (#1286)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1286
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Drew Inglis <[email protected]>
2026-10-05 11:05:17 +00:00
711b72735b chore(deps): update dependencies (#1288)
Co-authored-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1288
Reviewed-by: bircni <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-10-05 04:43:34 +00:00
silverwind 436d0d2942 fix: recognize Gitea's 401 reply for an unregistered runner (#1287)
Since https://github.com/go-gitea/gitea/pull/39578 (backported to v28), Gitea answers an unregistered runner with a Connect `Unauthenticated` error instead of a gRPC status wrapped as `Unknown`. `IsRegistrationRejected` missed it, so an ephemeral runner kept its spent registration file and failed on every restart. This also fails the `gitea/gitea:main-nightly` e2e job.

Match on the `unregistered runner` message suffix, which both shapes share. The code can't distinguish Gitea from a proxy, because a proxy's 401 with a JSON body also decodes as a wire `Unauthenticated` error.

Written by Claude Code.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1287
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-05 03:34:00 +00:00
silverwind a284a62d96 chore(deps): update gitea.dev/actionslib to v1.3.0 (#1278)
Updates to [actionslib v1.3.0](https://gitea.com/gitea/actionslib/releases/tag/v1.3.0). Matrix include and exclude entries now match combinations with expression equality like GitHub, so an include of `os: LINUX` extends the `os: linux` combination instead of adding a new one. No code changes are needed.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1278
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-02 18:53:13 +00:00
bircni 2b1e64e414 ci: Skip builds on push on main (#1276)
These builds were already tested on pr merge so why run again?

Reviewed-on: https://gitea.com/gitea/runner/pulls/1276
Reviewed-by: silverwind <[email protected]>
2026-10-01 21:21:38 +00:00
silverwind ddee2e054a fix: align Docker actions, job env and host mode with GitHub (#1268)
- 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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-01 19:45:13 +00:00
silverwindandbircni 629e062f86 fix: drop stalled HTTP/2 connections in action clones (#1266)
go-git sends all concurrent action clones from a host over one HTTP/2 connection, and Go sends no health-check pings by default. When that connection stalls, every clone on it hangs until the job timeout and cancellation can't free them, as in https://gitea.com/gitea/runner/actions/runs/1003885/jobs/1361593.

Ping idle connections so a stalled one is dropped after about 45s.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1266
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-01 16:48:19 +00:00
bircniandsilverwind 73e9552f06 fix: fail fast in register and honor GITEA_MAX_REG_ATTEMPTS (#1197)
`gitea-runner register` pinged the instance in an unbounded loop, so a permanent failure like an untrusted certificate or a wrong URL never returned. It now pings once and returns the error, like other one-shot commands.

The image entrypoint keeps retrying registration indefinitely by default, like the daemon, so a container without a restart policy still recovers once Gitea is reachable. `GITEA_MAX_REG_ATTEMPTS` had no effect because its counter was incremented outside the loop. When set, it now caps the attempts, after which the entrypoint exits non-zero.

Fixes: https://gitea.com/gitea/runner/issues/1195
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1197
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-01 16:01:14 +00:00
silverwindandbircni 05162998eb fix: dind image readiness, exit codes and shutdown order (#1265)
Fixes https://gitea.com/gitea/runner/issues/1263

- Wait for dockerd without a check limit. s6 readiness gave up after 7 checks, so the runner never started on slow hosts, also seen in https://gitea.com/gitea/runner/issues/712, https://gitea.com/gitea/runner/issues/652 and https://gitea.com/gitea/runner/issues/758.
- Let the runner run the docker service's readiness check itself instead of reading s6 status, which can be missing at startup or stale after an unclean stop.
- Exit the container with the first failing service's exit code instead of always 0, so `restart: on-failure` works. A requested stop still exits 0.
- Stop the runner before dockerd, so running jobs can finish within `shutdown_timeout`. The stop grace period has to cover that drain.
- Check the supervision exit codes in `make test-dind`.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1265
Reviewed-by: bircni <[email protected]>
2026-10-01 13:22:25 +00:00
568f89d186 enhance: export the Prometheus metrics over OpenTelemetry (#1248)
Push the runner's Prometheus metrics to the OTLP endpoint next to the job traces, so both land in the same backend.

The `/metrics` registry is converted as the [Prometheus compatibility spec](https://opentelemetry.io/docs/specs/otel/compatibility/prometheus_and_openmetrics/) describes and sent through the existing gRPC-free client, so names stay unchanged and no second metrics stack is linked. Metrics are on by default and pushed every interval and once on shutdown, so ephemeral runners report too.

New standard variables, each `OTEL_EXPORTER_OTLP_*` one with `TRACES` and `METRICS` variants:

- `OTEL_METRICS_EXPORTER`, `none` turns metrics off
- `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_METRIC_EXPORT_TIMEOUT`
- `OTEL_EXPORTER_OTLP_TIMEOUT`, `OTEL_EXPORTER_OTLP_COMPRESSION`
- `OTEL_EXPORTER_OTLP_CERTIFICATE`, `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE`, `OTEL_EXPORTER_OTLP_CLIENT_KEY`

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1248
Reviewed-by: silverwind <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: wmTJc9IK0Q <[email protected]>
2026-10-01 12:55:16 +00:00
bircniandsilverwind d93b7d278a fix: clear consumed ephemeral registration state (#1256)
Ephemeral registrations are single-use, but the daemon kept `.runner` after the task, so a restarted container failed `Declare` with `unregistered runner` forever.

An ephemeral runner now removes its registration file as soon as it fetches a task, so a container killed mid-job registers afresh on restart. It also removes the file when Gitea answers `unregistered runner` at startup or while polling. A proxy's 401 does not count. Persistent runners keep their file, because Gitea sends the same reply for a token mismatch.

Closes https://gitea.com/gitea/runner/issues/818

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1256
Reviewed-by: silverwind <[email protected]>
2026-10-01 09:23:45 +00:00
0cedd98051 feat: add Kubernetes backend (#1260)
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 <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1260
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: Zettat123 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-01 06:59:15 +00:00
07ca4bfa7f enhance: log task, job and run identifiers when a task starts (#1257)
Replace the `task N repo is ...` line with a structured `task started` entry carrying `task_id`, `job_id`, `run_id`, `run_number`, `repository` and the workflow's `job` key, so external supervisors can match runner activity to the job in Gitea. `job_id` is read from the runtime token's claims, tokens and secrets are never logged.

Closes https://gitea.com/gitea/runner/issues/752

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1257
Reviewed-by: silverwind <[email protected]>
2026-09-30 19:42:52 +00:00
78610b01f6 docs: recommend DinD for container jobs (#1258)
Make Docker-in-Docker the recommended and first-shown setup. Mounting the host's Docker socket gives every job root-equivalent access to the host, while DinD gives jobs their own daemon. The default `latest` tag is unchanged.

Also shorten the image flavour docs to what users need to pick an image, and correct that `dind-rootless` needs `--privileged` too.

Closes https://gitea.com/gitea/runner/issues/1227

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1258
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-09-30 18:27:11 +00:00
bircniandsilverwind 4a8ee4ce57 fix: resolve container sleep through PATH (#1255)
Resolve the job container's sleep command through PATH so images with nonstandard binary locations, including Nix, can run jobs.

Closes https://gitea.com/gitea/runner/issues/808

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1255
Reviewed-by: silverwind <[email protected]>
2026-09-29 18:44:57 +00:00
bircniandsilverwind bf03f36d5a fix: honor configured Docker registration file (#1259)
The Docker entrypoint always checked `.runner` in `/data` and ignored `runner.file` from `CONFIG_FILE`. When `runner.file` pointed elsewhere, it re-registered on every start, or skipped registration because of a stale `.runner`. It now reads the path from a new `config runner-file` subcommand, which resolves `runner.file` the same way as `register` and `daemon`.

Closes https://gitea.com/gitea/runner/issues/585

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1259
Reviewed-by: silverwind <[email protected]>
2026-09-29 18:38:35 +00:00
Renovate Botandsilverwind 5a38a3d0c4 chore(deps): update dependencies (#1254)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update |
|---|---|---|---|---|---|
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `v4.10.0` → `v4.10.2` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fbmatcuk%2fdoublestar%2fv4/v4.10.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fbmatcuk%2fdoublestar%2fv4/v4.10.0/v4.10.2?slim=true) | require | patch |
| [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) | `v0.5.16` → `v0.5.17` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fulikunitz%2fxz/v0.5.17?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fulikunitz%2fxz/v0.5.16/v0.5.17?slim=true) |  | patch |
| nginx | `62ff208` → `df221db` |  |  |  | digest |

---

### Release Notes

<details>
<summary>bmatcuk/doublestar (github.com/bmatcuk/doublestar/v4)</summary>

### [`v4.10.2`](https://github.com/bmatcuk/doublestar/releases/tag/v4.10.2): Better support for Windows "Junctions"

[Compare Source](https://github.com/bmatcuk/doublestar/compare/v4.10.1...v4.10.2)

Has improved support for reading "junctions" in Windows (ie, Windows' version of symlinks).

Thanks [@&#8203;x0Lazarus](https://github.com/x0Lazarus) for the PR!

#### What's Changed

- Fix Windows junction traversal with Go 1.23 and later by [@&#8203;x0Lazarus](https://github.com/x0Lazarus) in [#&#8203;117](https://github.com/bmatcuk/doublestar/pull/117)

#### New Contributors

- [@&#8203;x0Lazarus](https://github.com/x0Lazarus) made their first contribution in [#&#8203;117](https://github.com/bmatcuk/doublestar/pull/117)

**Full Changelog**: <https://github.com/bmatcuk/doublestar/compare/v4.10.1...v4.10.2>

### [`v4.10.1`](https://github.com/bmatcuk/doublestar/releases/tag/v4.10.1): Fixed issue with alternatives and character classes

[Compare Source](https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.1)

There was an issue where commas or ending curly brackets inside a character class would cause code looking for the next alternative (commas) or the end of alternatives (ending curly brackets) to erroneously match those characters inside the character classes.

Thanks to [@&#8203;sidsri14](https://github.com/sidsri14) for the fix!

#### What's Changed

- fix brace alternation scanning with character classes by [@&#8203;sidsri14](https://github.com/sidsri14) in [#&#8203;115](https://github.com/bmatcuk/doublestar/pull/115)

#### New Contributors

- [@&#8203;sidsri14](https://github.com/sidsri14) made their first contribution in [#&#8203;115](https://github.com/bmatcuk/doublestar/pull/115)

**Full Changelog**: <https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.1>

</details>

<details>
<summary>ulikunitz/xz (github.com/ulikunitz/xz)</summary>

### [`v0.5.17`](https://github.com/ulikunitz/xz/compare/v0.5.16...v0.5.17)

[Compare Source](https://github.com/ulikunitz/xz/compare/v0.5.16...v0.5.17)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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-->

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1254
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-28 09:49:36 +00:00
silverwindandbircni 6618e67645 fix: apply a job container without image to the runs-on image (#1252)
Fixes https://gitea.com/gitea/runner/issues/1251, a regression from https://gitea.com/gitea/runner/pulls/1233.

A job `container:` without an `image` key configures the runs-on image again, as in 3.x, so its `volumes`, `options`, `credentials` and `env` apply. An image that evaluates to empty still skips the container, as on GitHub.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1252
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-28 09:43:48 +00:00
silverwind 2b599fae7f fix: misc bug fixed and improvements (#1253)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-28 05:53:41 +00:00
Zettat123 990ebaa2ac fix: restore the caller's event and inputs in reusable workflow jobs (#1250)
Fixes https://github.com/go-gitea/gitea/issues/38705

Gitea side: https://github.com/go-gitea/gitea/pull/39452

Jobs of a called workflow currently see `gitea.event_name == "workflow_call"`, and their `gitea.event.inputs` is replaced by the caller's `with:`. On GitHub they keep the caller's event, and their `inputs` are the run's `workflow_dispatch` inputs overlaid with the caller's `with:`.

For compatibility with older runners, Gitea keeps sending that form and adds a `gitea_workflow_call` key with the original trigger event name and inputs of the run, and the `inputs` Gitea resolved for the job. When the key is present, the runner restores `gitea.event_name` and `gitea.event.inputs` from it, and uses the resolved `inputs` for the job's expressions and the setup log.

Without that key (older Gitea, or jobs outside called workflows), nothing changes.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1250
Reviewed-by: bircni <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-09-27 16:13:14 +00:00
bircniandsilverwind 81add27459 feat(cache): add shared S3-compatible storage (#1244)
Store the cache used by `actions/cache` in an S3-compatible bucket such as AWS S3 or RustFS, with no `cache-server`. Runners with the same settings use the same cache.

A cache saved on one runner can be restored on the others within 30 seconds. The runner talks to the bucket over the S3 REST API with SigV4, without an SDK. Lifecycle rules on the bucket expire old caches.

*Description rewritten by Claude.*

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1244
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-09-24 19:46:12 +00:00
silverwindandbircni ace8653105 feat!: serve the actions cache from the runner (#1229)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1229
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-24 18:56:07 +00:00
silverwindandbircni 95daa50d9b enhance!: match valid_volumes with bmatcuk/doublestar (#1214)
`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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1214
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-24 18:51:12 +00:00
1952e5cdb6 feat: OpenTelemetry tracing (#1206)
Adds OpenTelemetry tracing, configured through the standard `OTEL_*` environment variables and off until an OTLP endpoint is set.

![image.png](https://gitea.com/attachments/c4b49cdf-a32d-403c-94e9-30cd06ee6477)

Each job becomes a trace with a span per step, so operators can see where CI time goes in their existing observability backend. Jobs receive only `TRACEPARENT`, so instrumented tools inside them join the trace without seeing the collector's credentials. Metrics stay on the Prometheus endpoint, which a collector can scrape. See [docs/telemetry.md](https://gitea.com/wmTJc9IK0Q/runner/src/branch/otel/docs/telemetry.md) for setup and security notes.

Also keeps `GITHUB_SHA` and `GITHUB_REF` fixed for a whole run when HEAD moves during `exec` runs and the act tests.

[image.png](https://gitea.com/attachments/c4b49cdf-a32d-403c-94e9-30cd06ee6477)

[image.png](https://gitea.com/attachments/c4b49cdf-a32d-403c-94e9-30cd06ee6477)

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1206
Reviewed-by: bircni <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: wmTJc9IK0Q <[email protected]>
2026-09-24 15:57:18 +00:00
7ad12038d1 feat(runner): add built-in actions framework with checkout (#871)
Adds built-in actions, native Go actions shipped with the runner and selected with `uses: builtin:<name>`. Jobs need no action download and no Node in their image.

The first one is `builtin:checkout`, a subset of `actions/checkout` with `repository`, `ref`, `token`, `path` and `fetch-depth`. Other inputs fail the step. The runner clones shallowly on its host and copies the tree into the job, while `gitea-runner exec` copies the local working directory for the workflow's own ref.

`actions/checkout` and other remote actions are unchanged. A clone ref of `HEAD` now shallow-clones the remote's default branch, which checkout uses for other repositories.

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/871
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-09-24 15:47:58 +00:00
silverwind 98816a605c fix: skip jobs whose if fails to evaluate, update actionslib (#1247)
Updates actionslib to https://gitea.com/gitea/actionslib/releases/tag/v1.2.1.

1. A job whose `if:` fails to evaluate, like `fromJSON` of an invalid job output, is skipped with the error in its log instead of failing, like on GitHub Actions.
1. Workflows the runner reads itself, like with `exec`, are rejected when an expression does not parse, like an unclosed `${{` or an unknown context, instead of failing later.
1. Task payloads from Gitea are read without that check, because they hold evaluated text that may contain `${{`.
1. `if: False`, `if: ~` and other boolean, null or number conditions work.

Gitea PR: https://github.com/go-gitea/gitea/pull/39358

Reviewed-on: https://gitea.com/gitea/runner/pulls/1247
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-24 14:57:40 +00:00
silverwindandbircni e0d00e543d feat: update to actionslib v1.2.0, support self: (#1233)
Update to [actionslib v1.2.0](https://gitea.com/gitea/actionslib/releases/tag/v1.2.0) and move logic to it.

1. `self:{owner}/{repo}[/{path}]@{ref}` in step `uses` resolves on the runner's Gitea instance, for https://github.com/go-gitea/gitea/issues/38990.
2. `strategy.job-index` and `strategy.job-total` come from Gitea when it sends them.
3. Container, services and `with` are evaluated once instead of on every use.
4. Fixes a flaky clone-lock test.

Gitea PR: https://github.com/go-gitea/gitea/pull/39358
Docs PR: https://gitea.com/gitea/docs/pulls/553

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1233
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-23 15:40:19 +00:00
silverwindandbircni ae9ce10f71 fix: register docker-init as subreaper in the dind images (#1241)
The dind images run `s6-svscan` as PID 1, so the `docker-init` that the upstream entrypoint puts in front of `dockerd` cannot reap and warns on every start. `TINI_SUBREAPER` makes it a subreaper for the daemon's subtree, which is what tini recommends when it is not PID 1.

The warning was cosmetic. Orphaned `containerd-shim` processes re-parented to `s6-svscan`, which reaps them just as well, so no zombies accumulated. With this change they re-parent to `docker-init` instead, and the warning is gone.

Seen in https://gitea.com/gitea/runner/issues/1240

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1241
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-23 15:27:15 +00:00
silverwind 3962d0d970 fix: composite pre/post conditions read the job status (#1242)
A composite action's child run context starts with empty step results, so a nested action's `post-if: success()` saw the composite's own result and still ran after the job had failed, which let `actions/cache` save caches from failed jobs.

The `job` context now always comes from the job's run context. Only `success()` and `failure()` in a composite's own main steps read the composite's result, matching GitHub Actions.

Assisted-by: Claude Code:claude-fable-5-1
Reviewed-on: https://gitea.com/gitea/runner/pulls/1242
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-23 15:13:49 +00:00
silverwind d83fa77de4 enhance: support --umask in container.options (#1238)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-22 01:20:16 +00:00
Renovate Bot 58ed5921f3 chore(deps): update dependencies (#1236)
This PR contains the following updates:

| Package | Type | Update | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|---|
| [docker/build-push-action](https://github.com/docker/build-push-action) ([changelog](https://github.com/docker/build-push-action/compare/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a..c3c9e263c25d99ce0380d002d59b67737d91b0dc)) | action | digest | `53b7df9` → `c3c9e26` |  |  |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) ([changelog](https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e..f87e5991a6d7451dcb8d9637bfbc97413f497069)) | action | digest | `37fe631` → `f87e599` |  |  |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) ([changelog](https://github.com/docker/setup-qemu-action/compare/1f40c72289eff860ee54a304f1438e3cff362e0a..99012661954931238ded8c8b007157a8430204e1)) | action | digest | `1f40c72` → `9901266` |  |  |
| [github.com/docker/cli](https://github.com/docker/cli) | require | patch | `v29.8.0+incompatible` → `v29.8.1+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.8.1+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.8.0+incompatible/v29.8.1+incompatible?slim=true) |
| nginx |  | digest | `72ba65e` → `62ff208` |  |  |
| [node](https://github.com/nodejs/node) |  | digest | `6dac556` → `64af381` |  |  |
| [node](https://github.com/nodejs/node) |  | digest | `2fe369e` → `0e0ff40` |  |  |

---

### Release Notes

<details>
<summary>docker/cli (github.com/docker/cli)</summary>

### [`v29.8.1+incompatible`](https://github.com/docker/cli/compare/v29.8.0...v29.8.1)

[Compare Source](https://github.com/docker/cli/compare/v29.8.0...v29.8.1)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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/1236
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-21 12:22:51 +00:00
silverwind 00ff71a879 chore: remove CLAUDE.md (#1235)
Ref: https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md#21277
Reviewed-on: https://gitea.com/gitea/runner/pulls/1235
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-19 10:40:47 +00:00
silverwind 80db5f61ac fix: pin the podman test image to its immutable tag (#1234)
`quay.io/podman/stable` rebuilds its version tags every night and old digests stop resolving within a day, which breaks `make test-dind TARGET=podman`.

Pin `quay.io/containers/podman:v5.8.4-immutable` instead, its tags are never overwritten, see the [image_build README](https://github.com/containers/image_build/blob/main/README.md).

_Written by Claude._

Reviewed-on: https://gitea.com/gitea/runner/pulls/1234
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-19 10:34:01 +00:00
silverwind 5310d8119a fix: docker proxy probe for podman and containerized runners (#1231)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-17 20:01:45 +00:00
silverwind b9c6305dee test: stop the cancellation e2e test racing the job it watches (#1230)
`waitForRunningJobLog` gave one minute to run creation, scheduling, the container start and the first log flush, then blamed the missing log line, which is how https://gitea.com/gitea/runner/actions/runs/927661 failed. It now gets the suite's `runTimeout` and reports the job's status with the run logs on timeout, and the job holds ten seconds instead of two, so neither the marker nor the cancellation arrives after it has exited.

Against gitea 1.28+ this costs nothing, since the cancel ends the job as soon as the marker appears. Against 1.27, which has no cancel route and waits the job out, the suite goes from about 20s to about 23s, as the tests run in parallel.

*Written by Claude.*

Reviewed-on: https://gitea.com/gitea/runner/pulls/1230
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-16 18:40:50 +00:00
silverwind 699a97fc8c enhance: bind-mount job paths through the docker proxy (#1226)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-14 20:21:58 +00:00
silverwind 19afebc53f fix: send artifacts to Gitea when jobs cannot reach the cache server (#1225)
Container jobs on a Docker bridge network isolated from the runner's cache server now upload artifacts to Gitea directly instead of timing out, with a job log warning on how to make the cache reachable.

Fixes https://gitea.com/gitea/runner/issues/1211

Reviewed-on: https://gitea.com/gitea/runner/pulls/1225
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-14 19:52:15 +00:00
silverwindandbircni 2cc3000369 fix: mount the job workspace above the repository (#1224)
Mount the job workspace volume, or the `bind_workdir` directory, above `<owner>/<repo>` instead of at the repository, like GitHub's work directory.

With the repository as a mount point, pnpm 12 puts its store in `node_modules/.pnpm-store` (https://github.com/pnpm/pnpm/pull/13536), so a restored `setup-node` pnpm cache creates `node_modules` before install. Renames into `$RUNNER_WORKSPACE` fail with `EXDEV` and removing the workspace fails with `EBUSY`.

Verified against GitHub hosted and container jobs with cold and warm caches, on volume and `bind_workdir` runners, covering checkout, cache, artifacts, github-script, the node, pnpm, bun, python, uv, go, rust, java, gradle, dotnet, ruby and terraform setup actions, docker and Dockerfile actions, buildx, compose via `GITEA_DOCKER_WORKSPACE`, services, host mode, `container.volumes` on the workspace and `exec`.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1224
Co-authored-by: silverwind <[email protected]>
2026-09-14 18:58:19 +00:00
silverwindandbircni 3d116eb0c2 fix(deps): update to actionslib v1.0.0 (#1221)
Updates `gitea.dev/actionslib` to v1.0.0, see https://gitea.com/gitea/actionslib/pulls/17:

1. First-party expression parser passing GitHub's full conformance suite, replacing actionlint
1. Expression results match GitHub, including truthiness, numbers, `fromJSON`, `toJSON` and `hashFiles` cache keys
1. Whole-value `${{ }}` works for `strategy`, `env`, `with`, `services` and `outputs`
1. Matrix validation matches GitHub

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1221
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-14 18:41:37 +00:00
e0ce6776c7 perf(cache): skip redundant store writes and scans on cache requests (#1222)
Every cache hit rewrote the entry's access time, costing a bolt write and fsync per restore. Completed entries accessed within the last minute are no longer rewritten, and the in-use and retention windows grow by that minute so nothing is evicted earlier. Same-second rewrites are skipped, reservations are inserted in one transaction, and prefix lookups use the repository index instead of scanning every entry.

The store is still opened per request. Keeping it open, as this commit first did, is faster under concurrency but stops a second process sharing the cache directory from starting its cache server.

The README caching section is condensed and corrected.

| Benchmark, v2 | main | store kept open | this PR |
| --- | ---: | ---: | ---: |
| Exact hit, 1 client | 16.42 | 0.15 | 0.24 |
| Exact hit, 8 clients | 13.70 | 0.08 | 1.28 |
| Exact hit, 32 clients | 16.01 | 0.07 | 4.26 |
| Prefix hit, 8 clients | 18.34 | 0.12 | 1.28 |
| Miss, 8 clients | 3.21 | 0.11 | 1.28 |
| Save and restore 1 MiB, 8 clients | 74.4 | 22.9 | 34.9 |
| Geomean, all 22 benchmarks | 14.03 | 0.31 | 2.11 |

Mean ms per request, medians of 4 rounds of 300 requests over 1,000 entries on an Apple M3 Max.

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1222
Reviewed-by: silverwind <[email protected]>
2026-09-14 18:35:13 +00:00
Renovate Bot 69d5c22300 chore(deps): update dependencies (#1223)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update |
|---|---|---|---|---|---|
| [connectrpc.com/connect](https://github.com/connectrpc/connect-go) | `v1.20.0` → `v1.21.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/connectrpc.com%2fconnect/v1.21.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/connectrpc.com%2fconnect/v1.20.0/v1.21.0?slim=true) | require | minor |
| [github.com/docker/cli](https://github.com/docker/cli) | `v29.7.2+incompatible` → `v29.8.0+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.8.0+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.7.2+incompatible/v29.8.0+incompatible?slim=true) | require | minor |
| [github.com/moby/moby/api](https://github.com/moby/moby) | `v1.55.0` → `v1.56.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fmoby%2fapi/v1.56.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fmoby%2fapi/v1.55.0/v1.56.0?slim=true) | require | minor |
| [github.com/moby/moby/client](https://github.com/moby/moby) | `v0.5.1` → `v0.6.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fmoby%2fclient/v0.6.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fmoby%2fclient/v0.5.1/v0.6.0?slim=true) | require | minor |
| [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) | [`v0.58.0` → `v0.59.0`](https://cs.opensource.google/go/x/net/+/refs/tags/v0.58.0...refs/tags/v0.59.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fnet/v0.59.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fnet/v0.58.0/v0.59.0?slim=true) | require | minor |
| [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) | [`v0.22.0` → `v0.23.0`](https://cs.opensource.google/go/x/sync/+/refs/tags/v0.22.0...refs/tags/v0.23.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fsync/v0.23.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fsync/v0.22.0/v0.23.0?slim=true) | require | minor |
| [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) | [`v0.47.0` → `v0.48.0`](https://cs.opensource.google/go/x/sys/+/refs/tags/v0.47.0...refs/tags/v0.48.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fsys/v0.48.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fsys/v0.47.0/v0.48.0?slim=true) | require | minor |
| [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) | [`v0.45.0` → `v0.46.0`](https://cs.opensource.google/go/x/term/+/refs/tags/v0.45.0...refs/tags/v0.46.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fterm/v0.46.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fterm/v0.45.0/v0.46.0?slim=true) | require | minor |
| [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) | [`v0.41.0` → `v0.42.0`](https://cs.opensource.google/go/x/text/+/refs/tags/v0.41.0...refs/tags/v0.42.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2ftext/v0.42.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2ftext/v0.41.0/v0.42.0?slim=true) | require | minor |
| golang.org/x/vuln | `v1.7.0` → `v1.8.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fvuln/v1.8.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fvuln/v1.7.0/v1.8.0?slim=true) |  | minor |
| [node](https://github.com/nodejs/node) | `be23f54` → `6dac556` |  |  |  | digest |
| [node](https://github.com/nodejs/node) | `ba849c6` → `2fe369e` |  |  |  | digest |
| [tags.cncf.io/container-device-interface](https://github.com/cncf-tags/container-device-interface) | `v1.1.0` → `v1.1.1` | ![age](https://developer.mend.io/api/mc/badges/age/go/tags.cncf.io%2fcontainer-device-interface/v1.1.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/tags.cncf.io%2fcontainer-device-interface/v1.1.0/v1.1.1?slim=true) | require | patch |

---

### Release Notes

<details>
<summary>connectrpc/connect-go (connectrpc.com/connect)</summary>

### [`v1.21.0`](https://github.com/connectrpc/connect-go/releases/tag/v1.21.0)

[Compare Source](https://github.com/connectrpc/connect-go/compare/v1.20.0...v1.21.0)

#### What's Changed

> \[!IMPORTANT]
>
> This release adds a security-related feature for servers. The new handler option [`WithRequestGate`](https://pkg.go.dev/connectrpc.com/connect#WithRequestGate) runs after the request headers are available and before any message is received. Use this new option to register checks (e.g. authentication) that should happen before the request is decompressed or unmarshaled, and before any interceptors are run. See the [documentation for details](https://connectrpc.com/docs/go/interceptors/#authentication).

##### Governance

- Add pkwarren as a maintainer by [@&#8203;bufdev](https://github.com/bufdev) in [#&#8203;929](https://github.com/connectrpc/connect-go/pull/929)  🎉

##### Enhancements

- Add WithRequestGate handler option by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;962](https://github.com/connectrpc/connect-go/pull/962)
- Create release binary for protoc-gen-connect-go by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;966](https://github.com/connectrpc/connect-go/pull/966)

##### Bugfixes

- Fix issue with user-agent getting inappropriately injected into request headers by [@&#8203;jhump](https://github.com/jhump) in [#&#8203;934](https://github.com/connectrpc/connect-go/pull/934)
- Fix client streaming operations blocking indefinitely after context cancellation by [@&#8203;haru0017](https://github.com/haru0017) in [#&#8203;937](https://github.com/connectrpc/connect-go/pull/937)
- Fix flaky HTTP2 testcase assertion by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;940](https://github.com/connectrpc/connect-go/pull/940)
- Fix data race and connection leak in duplexHTTPCall by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;938](https://github.com/connectrpc/connect-go/pull/938)
- Fix off-by-one in WithHTTPGetMaxURLSize URL length check by [@&#8203;fchimpan](https://github.com/fchimpan) in [#&#8203;955](https://github.com/connectrpc/connect-go/pull/955)
- Fix connect.WithGRPC typo in the missing client option error by [@&#8203;NotAFlightRisk](https://github.com/NotAFlightRisk) in [#&#8203;956](https://github.com/connectrpc/connect-go/pull/956)
- Only send a 304 when the request was a GET by [@&#8203;NotAFlightRisk](https://github.com/NotAFlightRisk) in [#&#8203;957](https://github.com/connectrpc/connect-go/pull/957)
- Fix panic in stream Spec and Peer when client construction failed by [@&#8203;NotAFlightRisk](https://github.com/NotAFlightRisk) in [#&#8203;959](https://github.com/connectrpc/connect-go/pull/959)
- Fix dropped headers on errStreamingClientConn by [@&#8203;mohit-bhandari45](https://github.com/mohit-bhandari45) in [#&#8203;964](https://github.com/connectrpc/connect-go/pull/964)

#### New Contributors

- [@&#8203;haru0017](https://github.com/haru0017) made their first contribution in [#&#8203;937](https://github.com/connectrpc/connect-go/pull/937)
- [@&#8203;dongjiang1989](https://github.com/dongjiang1989) made their first contribution in [#&#8203;945](https://github.com/connectrpc/connect-go/pull/945)
- [@&#8203;fchimpan](https://github.com/fchimpan) made their first contribution in [#&#8203;955](https://github.com/connectrpc/connect-go/pull/955)
- [@&#8203;NotAFlightRisk](https://github.com/NotAFlightRisk) made their first contribution in [#&#8203;956](https://github.com/connectrpc/connect-go/pull/956)
- [@&#8203;mohit-bhandari45](https://github.com/mohit-bhandari45) made their first contribution in [#&#8203;964](https://github.com/connectrpc/connect-go/pull/964)

**Full Changelog**: <https://github.com/connectrpc/connect-go/compare/v1.20.0...v1.21.0>

</details>

<details>
<summary>docker/cli (github.com/docker/cli)</summary>

### [`v29.8.0+incompatible`](https://github.com/docker/cli/compare/v29.7.2...v29.8.0)

[Compare Source](https://github.com/docker/cli/compare/v29.7.2...v29.8.0)

</details>

<details>
<summary>moby/moby (github.com/moby/moby/client)</summary>

### [`v0.6.0`](https://github.com/moby/moby/compare/v0.5.1...v0.6.0)

[Compare Source](https://github.com/moby/moby/compare/v0.5.1...v0.6.0)

</details>

<details>
<summary>cncf-tags/container-device-interface (tags.cncf.io/container-device-interface)</summary>

### [`v1.1.1`](https://github.com/cncf-tags/container-device-interface/releases/tag/v1.1.1)

[Compare Source](https://github.com/cncf-tags/container-device-interface/compare/v1.1.0...v1.1.1)

#### What's Changed

- spec: Fix comment to reference containerEdits field by [@&#8203;zrezke](https://github.com/zrezke) in [#&#8203;303](https://github.com/cncf-tags/container-device-interface/pull/303)
- cdi: inject mount UID/GID mappings if user NS is in use. by [@&#8203;klihub](https://github.com/klihub) in [#&#8203;288](https://github.com/cncf-tags/container-device-interface/pull/288)
- cdi,SPECS.md: allow empty cgroup permissions. by [@&#8203;klihub](https://github.com/klihub) in [#&#8203;301](https://github.com/cncf-tags/container-device-interface/pull/301)
- .github: bump golangci-lint to v2.9.0. by [@&#8203;klihub](https://github.com/klihub) in [#&#8203;307](https://github.com/cncf-tags/container-device-interface/pull/307)
- add local build and install procedure in README.md. by [@&#8203;fujitatomoya](https://github.com/fujitatomoya) in [#&#8203;305](https://github.com/cncf-tags/container-device-interface/pull/305)
- Revert to default spec dirs if none are specified by [@&#8203;elezar](https://github.com/elezar) in [#&#8203;310](https://github.com/cncf-tags/container-device-interface/pull/310)
- SPEC.md: fix a few typos/clarify a sentence. by [@&#8203;klihub](https://github.com/klihub) in [#&#8203;312](https://github.com/cncf-tags/container-device-interface/pull/312)
- add beginner's tutorial to see how CDI works. by [@&#8203;fujitatomoya](https://github.com/fujitatomoya) in [#&#8203;311](https://github.com/cncf-tags/container-device-interface/pull/311)
- Makefile: add GO\_EXTRAFLAGS to go build call by [@&#8203;koenkooi](https://github.com/koenkooi) in [#&#8203;313](https://github.com/cncf-tags/container-device-interface/pull/313)
- Remove runtime-tools dependency by [@&#8203;elezar](https://github.com/elezar) in [#&#8203;317](https://github.com/cncf-tags/container-device-interface/pull/317)
- schema: add validation for top-level container edits by [@&#8203;marquiz](https://github.com/marquiz) in [#&#8203;323](https://github.com/cncf-tags/container-device-interface/pull/323)
- parser: guard single-character vendor and class names against a slice-bounds panic by [@&#8203;thc1006](https://github.com/thc1006) in [#&#8203;321](https://github.com/cncf-tags/container-device-interface/pull/321)
- chore(deps): replace deprecated gopkg.in/yaml.v3 for go.yaml.in/yaml/v3 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;328](https://github.com/cncf-tags/container-device-interface/pull/328)
- schema: make validation error private by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;329](https://github.com/cncf-tags/container-device-interface/pull/329)
- pkg/cdi: stop embedding Cache mutex by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;335](https://github.com/cncf-tags/container-device-interface/pull/335)
- build(deps): bump actions/stale from 10 to 11 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;327](https://github.com/cncf-tags/container-device-interface/pull/327)
- specs-go: add test coverage by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;332](https://github.com/cncf-tags/container-device-interface/pull/332)
- replace strings.Split for strings.Cut by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;336](https://github.com/cncf-tags/container-device-interface/pull/336)
- schema, validation: minor fixes and modernization by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;330](https://github.com/cncf-tags/container-device-interface/pull/330)
- pkg/cdi: deprecate ErrStopScan and use os.ReadDir for scanning Spec directories by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;339](https://github.com/cncf-tags/container-device-interface/pull/339)
- Bump actions/checkout from 6 to 7 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;318](https://github.com/cncf-tags/container-device-interface/pull/318)
- pkg/cdi: synchronize access to specDirs by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;337](https://github.com/cncf-tags/container-device-interface/pull/337)
- schema: migrate to github.com/santhosh-tekuri/jsonschema/v6 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;331](https://github.com/cncf-tags/container-device-interface/pull/331)
- pkg/cdi: fix deprecations, add doc-links and "go fix" by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;338](https://github.com/cncf-tags/container-device-interface/pull/338)
- internal/validation/k8s: remove unused validation helpers by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;334](https://github.com/cncf-tags/container-device-interface/pull/334)
- specs-go: remove x/mod semver dependency by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;333](https://github.com/cncf-tags/container-device-interface/pull/333)
- chore(deps): github.com/stretchr/testify v1.12.1 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;342](https://github.com/cncf-tags/container-device-interface/pull/342)
- ci: update golangci-lint to v2.13, and fix linting by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;352](https://github.com/cncf-tags/container-device-interface/pull/352)
- ci: apply zizmor hardening fixes by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;350](https://github.com/cncf-tags/container-device-interface/pull/350)
- specs-go: fix benchmark compatibility with go1.19 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;353](https://github.com/cncf-tags/container-device-interface/pull/353)
- pkg/cdi: fix flaky TestRefreshCache by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;355](https://github.com/cncf-tags/container-device-interface/pull/355)
- ci: test against oldest supported, oldstable, and stable Go versions by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;351](https://github.com/cncf-tags/container-device-interface/pull/351)
- pkg/cdi: TestRefreshCache: consolidate cache state checks by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;357](https://github.com/cncf-tags/container-device-interface/pull/357)
- build(deps): bump actions/checkout from 7.0.0 to 7.0.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;349](https://github.com/cncf-tags/container-device-interface/pull/349)
- remove "sigs.k8s.io/yaml" dependency by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;340](https://github.com/cncf-tags/container-device-interface/pull/340)
- build(deps): bump actions/setup-go from 6.4.0 to 7.0.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;348](https://github.com/cncf-tags/container-device-interface/pull/348)
- build(deps): bump golangci/golangci-lint-action from 9.2.1 to 9.3.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;346](https://github.com/cncf-tags/container-device-interface/pull/346)
- build(deps): bump codespell-project/actions-codespell from 2.1 to 2.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;347](https://github.com/cncf-tags/container-device-interface/pull/347)
- pkg/cdi: fix, and cleanup some locks, and use RWMutex by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;345](https://github.com/cncf-tags/container-device-interface/pull/345)
- update minimum go version to go1.23 and modernize some code by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;354](https://github.com/cncf-tags/container-device-interface/pull/354)
- chore(deps): github.com/fsnotify/fsnotify v1.7.0 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;359](https://github.com/cncf-tags/container-device-interface/pull/359)
- pkg/cdi: clean up watcher lifecycle handling and simplify refresh logic by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;358](https://github.com/cncf-tags/container-device-interface/pull/358)
- pkg/cdi: fix cache refresh handling for filesystem events by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;344](https://github.com/cncf-tags/container-device-interface/pull/344)
- Bump version to v1.1.1 by [@&#8203;elezar](https://github.com/elezar) in [#&#8203;361](https://github.com/cncf-tags/container-device-interface/pull/361)

#### New Contributors

- [@&#8203;zrezke](https://github.com/zrezke) made their first contribution in [#&#8203;303](https://github.com/cncf-tags/container-device-interface/pull/303)
- [@&#8203;fujitatomoya](https://github.com/fujitatomoya) made their first contribution in [#&#8203;305](https://github.com/cncf-tags/container-device-interface/pull/305)
- [@&#8203;koenkooi](https://github.com/koenkooi) made their first contribution in [#&#8203;313](https://github.com/cncf-tags/container-device-interface/pull/313)
- [@&#8203;thc1006](https://github.com/thc1006) made their first contribution in [#&#8203;321](https://github.com/cncf-tags/container-device-interface/pull/321)

**Full Changelog**: <https://github.com/cncf-tags/container-device-interface/compare/v1.1.0...v1.1.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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/1223
Reviewed-by: bircni <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-14 10:58:44 +00:00
silverwind 54978255f5 perf: cut redundant work out of job setup and teardown (#1218)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-10 19:00:06 +00:00
silverwindandbircni 498282caaa fix: IPv6 URLs, cache-server SIGTERM and atomic task admission (#1217)
Three independent fixes, one commit each.

1. Hosts and ports were interpolated directly when building URLs, so a literal IPv6 address produced an unbracketed authority, making `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` and the artifact server listener unusable. Hosts are expected bare, as documented for `cache.host`, so an address that already carries brackets is no longer accepted.
2. `cache-server` waited on its own `os.Interrupt` channel and ignored SIGTERM, so service managers and container runtimes had to kill it.
3. Task admission used a separate `Load` and `Store`, so two concurrent dispatches of the same task id could both be admitted.

Assisted-by: Claude Code:Opus 5
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1217
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-09 15:58:09 +00:00
silverwind 2ed8cdb76e fix: stop artifact uploads depending on the cache server reaching Gitea (#1216)
Cache v2 makes the cache server the `ACTIONS_RESULTS_URL` origin, so artifact calls arrived there and were proxied on to Gitea, failing whenever it could not reach the instance.

- Artifact calls are answered with a redirect, so the cache server opens no connection to Gitea. A scheme change or an untrusted instance is still proxied, but there the cache server is the runner itself, which already reaches Gitea.
- Failures answer in twirp, not an empty `502` that clients report as `Unexpected end of JSON input`.
- `cache.v2: false` really points artifacts at Gitea now.
- Cache reservations are bound to the job that made them, so two jobs saving one key cannot commit against each other's upload, and a retry after a lost answer no longer fails a saved entry.
- The toolkit patch, which edits the GitHub-host check out of an action's bundle, was left in the shared checkout where a job running with `runner.patch_actions: false` could inherit it. It is put back after the job's copy.
- `exec` names an origin for the cache v2 it advertises, and masks its runtime token.

Behaviour changes: `no_proxy` no longer exempts `cache.external_server`, and `cache.enabled: false` also stops external registration.

Fixes https://gitea.com/gitea/runner/issues/1208
Fixes https://gitea.com/gitea/runner/issues/1211

Assisted by Claude (Opus 5).

Reviewed-on: https://gitea.com/gitea/runner/pulls/1216
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-08 18:45:10 +00:00
Zettat123andsilverwind ff9965e940 fix: stop the job's docker socket from becoming a directory (#1215)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1215
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-09-08 04:16:02 +00:00
Renovate Botandsilverwind ba4d3c5b4f chore(deps): update dependencies and tweak make checks (#1212)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1212
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-07 08:20:15 +00:00
silverwindandbircni 9e3647395a feat: add GITEA_DOCKER_WORKSPACE and container cleanup (#1204)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1204
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-05 08:58:23 +00:00
silverwind c158ac5472 perf: speed up action downloads (#1209)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-05 08:52:10 +00:00
1745c7c841 fix: fail the step or job whose expression cannot be interpolated (#1199)
An expression that failed to evaluate was logged and replaced by an empty string, so a `run:` step executed an empty script and reported success. The same swallow covered `shell:`, `working-directory:`, step `env:`, `with:`, `uses:`, the job's `env:`, `container:`, `services:`, `runs-on:` and outputs, and a called workflow's `with:` and `secrets:`. Every interpolation now propagates its error as actions/runner does: step-level values fail the step, job-level values fail the job at setup, `timeout-minutes` logs the error and runs unbounded, and a job or step name keeps its source text.

`defaults.run` and a called workflow's inputs and secrets are resolved once at job setup with the job context rather than per step, and the job's image is resolved once, so host mode and `ImageOS` derive from the image the job started with.

Closes https://gitea.com/gitea/runner/issues/392
Closes https://gitea.com/gitea/runner/issues/555

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1199
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-09-03 10:43:04 +00:00
Renovate Bot 7f53eca716 fix(deps): update module golang.org/x/crypto to v0.56.0 [security] (#1205)
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/crypto](https://pkg.go.dev/golang.org/x/crypto) | [`v0.55.0` → `v0.56.0`](https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.55.0...refs/tags/v0.56.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fcrypto/v0.56.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fcrypto/v0.55.0/v0.56.0?slim=true) |

---

### Prevent DoS on deadlocked undecided channel in golang.org/x/crypto/ssh
[CVE-2026-78662](https://nvd.nist.gov/vuln/detail/CVE-2026-78662) / [GO-2026-6354](https://pkg.go.dev/vuln/GO-2026-6354)

<details>
<summary>More information</summary>

#### Details
Previously, a channel registered in the mux's chanList is not usable until it is established. A malicious peer was able flood the channel's incomingRequests, deadlocking the entire connection.

Now, we add an atomic established state, set when a channel becomes usable. Until such a time, handlePacket drops every packet other than the open confirmation/failure, without blocking and without tearing down the connection.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81316](https://go.dev/issue/81316)
- [https://go.dev/cl/826504](https://go.dev/cl/826504)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6354) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Prevent DoS on deadlocked established channel in golang.org/x/crypto/ssh
[CVE-2026-56855](https://nvd.nist.gov/vuln/detail/CVE-2026-56855) / [GO-2026-6355](https://pkg.go.dev/vuln/GO-2026-6355)

<details>
<summary>More information</summary>

#### Details
Previously, after a channel has been established, a malicious peer could send crafted messages that would deadlock the entire connection.

Now, we handle all RFC 4254 channel messages; global requests are handled explicitly. Then, treat all other messages as a protocol error and tear the connection down instead of buffering and blocking.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81317](https://go.dev/issue/81317)
- [https://go.dev/cl/826524](https://go.dev/cl/826524)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6355) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/1205
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-03 06:40:31 +00:00
silverwind fca6b44c60 fix: automatically add the workspace mount to allowed volumes (#1203)
Automatically allow workspace mounts in `valid_volumes` for the mounts done via `bind_workdir`, this obsoletes the need for `/workspace/**` or other insecure configurations which would expose workspaces between tasks.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1203
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-02 20:32:41 +00:00
bircniandsilverwind b9018aca31 fix: preserve symlinked Node action entrypoints (#1202)
Node resolves an ESM main to its realpath but leaves `process.argv[1]` as passed. `/var/run/act` reaches the action through the `/var/run` -> `/run` symlink most images ship, so the two disagree and actions comparing them skip their own `run()`. Passing `--preserve-symlinks-main` makes them match.

Trade-off: an action whose `runs.main` is a symlink now resolves dependencies from the link's directory rather than the target's.

Fixes https://gitea.com/gitea/runner/issues/1201
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1202
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-08-31 13:06:49 +00:00
Renovate Bot 6c77065295 chore(deps): update dependencies (#1198)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [node](https://github.com/nodejs/node) | digest | `934240a` → `be23f54` |
| [node](https://github.com/nodejs/node) | digest | `3638d9a` → `ba849c6` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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/1198
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-08-29 02:00:21 +00:00
235c035003 feat: add input and input-file flags for exec command (#1173)
Add `input` and `input-file` flags for exec command and refactor common code from LoadVars and LoadEnvs.
Closes (https://gitea.com/gitea/runner/issues/1022)

---------

Co-authored-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1173
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Minjie Fang <[email protected]>
2026-08-27 16:38:01 +00:00
silverwind 12dc9d26a2 fix: improve behaviour across masking, commands and status (#1194)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-27 09:36:27 +00:00
silverwindandbircni d9f4d65545 fix: honor volumes declared on service containers (#1186)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1186
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-26 15:55:27 +00:00
ABiscuittttandsilverwind 212909db7b fix: keep a step's own with: values out of its inputs context (#1192)
A step's `if:`, its `continue-on-error:` and its `run:` script resolved `inputs.*` from the step's own `INPUT_*` env. A `with:` key colliding with a workflow input flipped conditions, and any `INPUT_`-shaped variable from `env:` or a `GITHUB_ENV` write forged an input that never existed.

GitHub evaluates all three in the enclosing scope: the workflow inputs, or for a composite action's steps that action's inputs. Action-input interpolation is the one place that legitimately sees a step's own `with:`, so it keeps its own evaluator.

Fixes https://gitea.com/gitea/runner/issues/1191, ports https://github.com/nektos/act/pull/2473 and extends it to the pre and post stages.

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1192
Reviewed-by: silverwind <[email protected]>
Co-authored-by: ABiscuitttt <[email protected]>
2026-08-26 14:45:05 +00:00
silverwind 0712b2a7a1 fix: mask secrets on every path they leave a job (#1188)
A secret in a matrix value reached the log in the clear:

```yaml
strategy:
  matrix:
    include: "${{ github.token }}"
```

Chasing that one route is pointless, so this masks every sink a secret leaves a job by: the uploaded log rows and the on-disk `job.log`, both through one choke point in `appendLogRow`; the runner's own log, which is where planning errors like that one land with no job logger in reach; the job logger's stdout under debug logging; job summaries; job outputs; and the job name that becomes a container name.

Values the runner knows but the job never declared, the proxy password and the task token, are hidden the same way. Masks apply longest first, since `strings.Replacer` matches in argument order and one secret prefixing another would otherwise mask the prefix and print the rest.

### What changes for users

An output whose value carries a secret is skipped with a warning instead of sent, matching GitHub. Output that showed a secret now shows `***`. `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG` are never masked, also matching GitHub, so an output of `true` still reaches the jobs that need it.

Each fix has a test that fails without it.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1188
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-25 20:36:05 +00:00
34df4887af fix: report step log ranges with the log flush (#1189)
Gitea slices a task's single log stream into steps by the LogIndex/LogLength that UpdateTask carries. The reporter's daemon flushed log rows without those counters, which only left on the separate state ticker, so rows the server acked between two state reports belonged to no step. The web UI attributes them to no step while the job runs, and a runner that stops reporting in that window leaves them under "Complete job" for good once Gitea finalizes the task.

The three log-flush paths now report the state describing the rows the server just took, so the gap is at most one RPC. Live output also stops waiting up to `state_report_interval` to become visible. An idle job adds no requests.

This does not explain why the runner in that job stopped reporting for 13m40s until Gitea reaped the task as a zombie, that needs the runner host's own log.

Fixes https://gitea.com/gitea/runner/issues/1184

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1189
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-08-25 12:13:18 +00:00
silverwind 745a1e70e6 fix: keep the runner's own container.options when privileged is off (#1151)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-24 22:13:28 +00:00
Renovate Botandsilverwind e30c2fed62 chore: update deps, adapt lint, use json v2 (#1185)
- Raised go to 1.27
- Adopted json v2
- Sync lint config from gitea
- Fixed all issues

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1185
Reviewed-by: bircni <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-08-24 19:53:17 +00:00
silverwind 7b4356c746 fix: fail the run when matrix expansion fails (#1187)
A `GetMatrixes` error was logged and discarded, leaving a nil matrix list. That collapsed `maxParallel` to zero, so no executor was built and the parallel executor returned nil for an empty list: the job reported success without running anything.

It now fails the run. Every error it returns is a workflow validation failure that GitHub rejects too, so nothing that runs there starts failing here.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1187
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-24 17:03:45 +00:00
Renovate Bot e78123cee3 fix(deps): update go toolchain directive to v1.26.6 [security] (#1183)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [go](https://go.dev/) ([source](https://github.com/golang/go)) | toolchain | patch | `1.26.5` → `1.26.6` |

---

### Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna
[CVE-2026-39821](https://nvd.nist.gov/vuln/detail/CVE-2026-39821) / [GO-2026-5026](https://pkg.go.dev/vuln/GO-2026-5026)

<details>
<summary>More information</summary>

#### Details
The ToASCII and ToUnicode functions incorrectly accept Punycode-encoded labels that decode to an ASCII-only label. For example, ToUnicode("xn--example-.com") incorrectly returns the name "example.com" rather than an error.

This behavior can lead to privilege escalation in programs using the idna package. For example, a program which performs privilege checks on the ASCII hostname may reject "example.com" but permit "xn--example-.com". If that program subsequently converts the ASCII hostname to Unicode, it will inadvertently permits access to the Unicode name "example.com".

#### Severity
Unknown

#### References
- [https://go.dev/cl/767220](https://go.dev/cl/767220)
- [https://go.dev/issue/78760](https://go.dev/issue/78760)
- [https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8](https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-5026) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Parsing an invalid SVCB or HTTPS RR can panic in golang.org/x/net/dns/dnsmessage
BIT-golang-2026-46600 / [CVE-2026-46600](https://nvd.nist.gov/vuln/detail/CVE-2026-46600) / [GO-2026-5942](https://pkg.go.dev/vuln/GO-2026-5942)

<details>
<summary>More information</summary>

#### Details
Parsing an invalid SVCB or HTTPS RR can panic when the size of a parameter value overflows the message buffer.

#### Severity
Unknown

#### References
- [https://go.dev/cl/786345](https://go.dev/cl/786345)
- [https://go.dev/issue/79795](https://go.dev/issue/79795)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-5942) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Enforce maximum recursion depth in encoding/asn1
BIT-golang-2026-33818 / [CVE-2026-33818](https://nvd.nist.gov/vuln/detail/CVE-2026-33818) / [GO-2026-5972](https://pkg.go.dev/vuln/GO-2026-5972)

<details>
<summary>More information</summary>

#### Details
Enforce a recursion limit in Unmarshal to prevent stack exhaustion when parsing deeply-nested, recursive structures.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80405](https://go.dev/issue/80405)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)
- [https://go.dev/cl/814980](https://go.dev/cl/814980)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-5972) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Add recursion depth guard during decode in encoding/xml
BIT-golang-2026-56859 / [CVE-2026-56859](https://nvd.nist.gov/vuln/detail/CVE-2026-56859) / [GO-2026-6088](https://pkg.go.dev/vuln/GO-2026-6088)

<details>
<summary>More information</summary>

#### Details
Previously, DecodeElement would reset the depth counter causing it to never fire; this could lead to stack exhaustion.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80481](https://go.dev/issue/80481)
- [https://go.dev/cl/803320](https://go.dev/cl/803320)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6088) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Apply ReadHeaderTimeout when doing unencrypted HTTP/2 check in net/http
BIT-golang-2026-56853 / [CVE-2026-56853](https://nvd.nist.gov/vuln/detail/CVE-2026-56853) / [GO-2026-6089](https://pkg.go.dev/vuln/GO-2026-6089)

<details>
<summary>More information</summary>

#### Details
When a server is configured to support unencrypted HTTP/2, it reads a few bytes from each new connection to see if they contain the HTTP/2 client preface. ReadHeaderTimeout is unexpectedly not being applied when doing this.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80205](https://go.dev/issue/80205)
- [https://go.dev/cl/795540](https://go.dev/cl/795540)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6089) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Limit handshake messages we are willing to accept post-handshake in crypto/tls
BIT-golang-2026-56862 / [CVE-2026-56862](https://nvd.nist.gov/vuln/detail/CVE-2026-56862) / [GO-2026-6090](https://pkg.go.dev/vuln/GO-2026-6090)

<details>
<summary>More information</summary>

#### Details
Handshake messages, such as KeyUpdate, are always considered as state-advancing, regardless of whether a handshake has been completed or not. As a result, a malicious client can keep sending KeyUpdate messages to force the server to keep performing key derivation operations indefinitely.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80528](https://go.dev/issue/80528)
- [https://go.dev/cl/804261](https://go.dev/cl/804261)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6090) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Fix Javascript regexp context tracking in html/template
BIT-golang-2026-56858 / [CVE-2026-56858](https://nvd.nist.gov/vuln/detail/CVE-2026-56858) / [GO-2026-6091](https://pkg.go.dev/vuln/GO-2026-6091)

<details>
<summary>More information</summary>

#### Details
Previously, pathological inputs could close an unescaped '/' early, allowing for attack-controlled data to inject arbitrary content, potentially leading to XSS.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80435](https://go.dev/issue/80435)
- [https://go.dev/cl/807100](https://go.dev/cl/807100)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6091) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Avoid quadratic complexity in resolvePath in net/url
BIT-golang-2026-56860 / [CVE-2026-56860](https://nvd.nist.gov/vuln/detail/CVE-2026-56860) / [GO-2026-6218](https://pkg.go.dev/vuln/GO-2026-6218)

<details>
<summary>More information</summary>

#### Details
Previously, resolving relative paths containing parent directory ('..') segments performed string conversions and buffer rewrites on each step, resulting in quadratic time complexity and high memory allocation overhead.

Now, path resolution operates on a byte buffer using index-based backtracking for '..' segments, eliminating the quadratic time complexity and significantly reducing memory allocations.

#### Severity
Unknown

#### References
- [https://go.dev/cl/803681](https://go.dev/cl/803681)
- [https://go.dev/issue/80494](https://go.dev/issue/80494)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6218) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- 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/1183
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-08-23 12:46:12 +00:00
bircniandsilverwind 8260e2def2 test: add end-to-end Gitea compatibility suite (#1180)
Adds a real-Gitea compatibility suite against stable and nightly to catch runner and API drift before release.

The suite shares one Gitea and regular runner, using repository runners only for cache v1/v2 and ephemeral behavior. It covers registration, payload and log encoding, secrets, variables, services, artifacts, outputs, matrices, cache, dispatch, live logs, cancellation, and ephemeral teardown.

CI runs both images in parallel. Warm local timings:

| Image | Before | After |
| --- | ---: | ---: |
| `gitea/gitea:latest` | ~70s | 26.54s |
| `gitea/gitea:main-nightly` | ~54s | 22.53s |

The former serial matrix took about 2 minutes. Parallel suite execution is now bounded by the slower ~26.5-second variant.

Shared Renovate matcher: https://gitea.com/gitea/renovate-config/pulls/552

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1180
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-08-23 12:32:22 +00:00
silverwind b97c61aa14 test: speed up tests (#1181)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-22 20:53:55 +00:00
silverwind 546eca312e refactor: remove unreachable runner code (#1179)
Remove inherited act APIs, configuration branches, and test seams that neither the daemon nor exec can reach. Constant-fold settings both entry points already enforce and consolidate duplicate runner paths.

Major removals:

- Unwired custom action-cache and local-repository-cache implementations.
- Legacy matrix, platform, input, container-reuse, logging, Git remote, and action-replacement configuration paths.
- Unused Docker socket, container network, tar-copy, and platform PTY wrappers.
- Single-implementation filesystem, environment, runner, and expression abstractions.
- Duplicated step-container, command-logging, credential, reusable-workflow, and execution paths.
- Generated client mock boilerplate, obsolete fixtures, test-only seams, stale wrappers, and commented-out code.

This removes 2844 net Go lines while retaining Gitea RPC, event, matrix, input, cache, artifact, action, reusable workflow, Docker, host, exec, and release behavior.

Assisted by Codex (GPT-5).

Reviewed-on: https://gitea.com/gitea/runner/pulls/1179
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-22 16:54:41 +00:00
bircniandsilverwind a0c4de79f7 feat: add log.job.dir (#1165)
`log.job.dir` makes the runner write a copy of every task's log to that directory, as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same masking and the job's result on the last line. Off by default, and what Gitea shows does not change.

`log.job.retention` (default `168h`) and `log.job.max_size` (default `1GB`) bound the directory. Documented in the README.

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1165
Reviewed-by: silverwind <[email protected]>
2026-08-21 07:25:11 +00:00
silverwind dfe979e1d0 fix: stop a failed step disabling the toolkit patch (#1177)
A step that failed, for any reason, made the runner restore the action's stock bundle and mark it never to be patched again. Every later `actions/upload-artifact` run then failed with `GHESNotSupportedError`, and nothing in the log said why.

The edit is now made as the action is copied into the job container, under the lock that guards the copy, and nothing reverts it. That also closes the race where another job's checkout reset the bundle mid-job.

`cache.v2` no longer decides whether the edit is made, it only withdraws the v2 advertisement, so artifacts work whatever the cache is set to.

Also added a new `runner.patch_actions` option to turn the edit off if it ever breaks an action.

Fixes https://gitea.com/gitea/runner/issues/1176

Reviewed-on: https://gitea.com/gitea/runner/pulls/1177
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-20 15:46:33 +00:00
bircniandsilverwind 11ac12efa4 enhance: add runner.default_image for jobs matching no label (#1164)
A job whose `runs-on` matches none of the runner's labels, which includes every job that sets no `runs-on` at all, runs in `runner.default_image`. It defaults to `docker.gitea.com/runner-images:ubuntu-latest` as before, so a mirror can be pointed at instead.

A runner with no reachable docker daemon now runs such a job on the host, rather than failing on an image it cannot pull. Runners that use docker are unaffected and never probe for one.

This matters most to a host-mode runner, one whose labels are all `host`. Such a runner has no daemon to pull an image with, so a job matching none of its labels used to fail at container start. It now runs on the host, where that runner runs everything else anyway, and it takes no configuration to get there. A host-mode runner that does have a daemon within reach keeps using the image, unchanged.

Supersedes https://gitea.com/gitea/runner/pulls/642

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1164
Reviewed-by: silverwind <[email protected]>
2026-08-19 18:36:00 +00:00
silverwind bc8161c673 fix: bound blocking calls and stop failing silently (#1174)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-19 11:13:21 +00:00
silverwindandbircni 3f70822458 enhance: add runner.tool_cache_mode and default it to none (#1171)
The current shared tools cache is not concurrency-safe, e.g. multiple jobs can write and corrupt it, for example `setup-go` with explicit go version under concurrency reliably corrupts the tool cache and fails all jobs.

This adds a new `runner.tool_cache_mode` (and `--tool-cache-mode` exec option) option which defaults to unshared tools cache:

- `none` mounts nothing, so a job uses what its image ships there and discards what it installs
- `shared` keeps the single volume every job reuses, and warns when `runner.capacity` is above 1

Under `none` effective tool cache can only come from the image or host, which is the same as it is on GitHub Actions which ships many preinstalled tools in its fat VM images.

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1171
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-18 20:16:11 +00:00
bircniandsilverwind be90c01468 feat: add size-based cache eviction (#1170)
The cache server retired entries 30 days after creation regardless of use, so a job that ran often enough to keep its cache warm still lost it on a fixed schedule. Nothing bounded the disk either.

Retention now counts from last access alone, and a repository over its limit sheds least recently accessed entries until it fits, enforced on commit as well as on the periodic sweep.

```yaml
cache:
  retention: 168h        # remove entries not accessed for seven days
  repo_size_limit: 10GB  # cap each repository
  size_limit: 0          # cap the whole cache, off by default
  sweep_interval: 1h     # minimum time between sweeps
```

Sizes accept `10GB`, `512mb`, `1TiB` or a plain byte count, binary either way. Leave a key out for its default; `0` turns a limit off, and `0s` does the same for `retention`. Whatever these allow, the cache also sheds entries to keep free space above `health_check.min_free_disk_space_mb` when health checks are enabled, so it cannot grow past the point where the runner stops accepting work.

Supporting fixes: serving an entry stamps its access time, so a find cannot hand a job a download URL for an entry the next eviction is about to remove; an entry larger than the limit is dropped on its own account rather than emptying its repository to make room; and a blob that cannot be unlinked keeps its row, so the next sweep retries instead of orphaning bytes no limit can account for.

Closes https://gitea.com/gitea/runner/issues/1168

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1170
Reviewed-by: silverwind <[email protected]>
2026-08-18 15:32:53 +00:00
bircni 6c6a878403 enhance: show workflow_dispatch inputs in the "Set up job" section (#1167)
Trigger-time inputs used to be visible in the job log but are no longer shown after the "Set up job" section was reshaped. This restores them as an "Inputs" group listing each provided input and its value, rendered only when the event payload carries inputs.

For a run dispatched with `required=required input`, `with_default=default` and `boolean=true`, the "Set up job" log now shows:

​```
gitea-com-gitea-0003(version:v3.0.2)
▸ Runner Information
    Task: 268506
    Job: test
    Repository: gitea/runner
    Triggered by event: workflow_dispatch
▸ Inputs
    boolean: true
    required: required input
    with_default: default
▸ Operating System
    Ubuntu 24.04.4 LTS
    linux/amd64
​```

Closes https://gitea.com/gitea/runner/issues/1166

Reviewed-on: https://gitea.com/gitea/runner/pulls/1167
Reviewed-by: Lunny Xiao <[email protected]>
2026-08-15 19:57:30 +00:00
Lunny Xiao f97680a68d chore: drop AWS S3 release upload, keep Cloudflare R2 only (#1169)
Release artifacts were uploaded to both AWS S3 and Cloudflare R2 during the migration period. Drop the goreleaser `blobs:` S3 pipe and the AWS_*/S3_* secrets from the release workflows, so artifacts are published to Cloudflare R2 only.

Assisted-by: Codet:GPT-5.1-Codex
Reviewed-on: https://gitea.com/gitea/runner/pulls/1169
Reviewed-by: bircni <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
2026-08-15 19:41:04 +00:00
Renovate Bot dbd9a892f8 chore(deps): update dependencies (#1160)
This PR contains the following updates:

| Package | Type | Update | Change | Pending | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|---|---|
| docker | stage | minor | `29.6.2-dind-rootless` → `29.7.1-dind-rootless` |  | ![age](https://developer.mend.io/api/mc/badges/age/docker/docker/29.7.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/docker/29.6.2/29.7.1?slim=true) |
| docker | stage | minor | `29.6.2-dind` → `29.7.1-dind` |  | ![age](https://developer.mend.io/api/mc/badges/age/docker/docker/29.7.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/docker/29.6.2/29.7.1?slim=true) |
| [github.com/docker/cli](https://github.com/docker/cli) | require | minor | `v29.6.2+incompatible` → `v29.7.1+incompatible` | `v29.7.2+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.7.1+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.6.2+incompatible/v29.7.1+incompatible?slim=true) |
| [github.com/moby/go-archive](https://github.com/moby/go-archive) | require | minor | `v0.2.1` → `v0.3.2` | `v0.3.3` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fgo-archive/v0.3.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fgo-archive/v0.2.1/v0.3.2?slim=true) |

---

### Release Notes

<details>
<summary>docker/cli (github.com/docker/cli)</summary>

### [`v29.7.1+incompatible`](https://github.com/docker/cli/compare/v29.7.0...v29.7.1)

[Compare Source](https://github.com/docker/cli/compare/v29.7.0...v29.7.1)

### [`v29.7.0+incompatible`](https://github.com/docker/cli/compare/v29.6.2...v29.7.0)

[Compare Source](https://github.com/docker/cli/compare/v29.6.2...v29.7.0)

</details>

<details>
<summary>moby/go-archive (github.com/moby/go-archive)</summary>

### [`v0.3.2`](https://github.com/moby/go-archive/releases/tag/v0.3.2)

[Compare Source](https://github.com/moby/go-archive/compare/v0.3.1...v0.3.2)

#### What's Changed

Fix a regression introduced in v0.3.0 that caused archive extraction to fail when paths traversed absolute symlinks inside the destination root, such as `var/run -> /run`. Absolute symlink targets are now resolved relative to the extraction root while relative symlink escapes remain rejected. [#&#8203;93](https://github.com/moby/go-archive/pull/93)

**Full Changelog**: <https://github.com/moby/go-archive/compare/v0.3.1...v0.3.2>

### [`v0.3.1`](https://github.com/moby/go-archive/releases/tag/v0.3.1)

[Compare Source](https://github.com/moby/go-archive/compare/v0.3.0...v0.3.1)

#### Fixes

This patch release fixes a regression introduced in v0.2.1 where archive extraction could fail when an archive omitted explicit entries for parent directories. For example, extracting `etc/dnf/` without a preceding `etc/` entry could return `mkdirat etc/dnf: no such file or directory`.

This prevented affected images from being extracted. Archive extraction now creates implied parent directories for both file and directory entries.

#### What's Changed

- archive: create implied parents for directory entries [#&#8203;92](https://github.com/moby/go-archive/pull/92)
- archive: Tarballer.Go: suppress io.ErrClosedPipe logs on close [#&#8203;94](https://github.com/moby/go-archive/pull/94)

**Full Changelog**: <https://github.com/moby/go-archive/compare/v0.3.0...v0.3.1>

### [`v0.3.0`](https://github.com/moby/go-archive/releases/tag/v0.3.0)

[Compare Source](https://github.com/moby/go-archive/compare/v0.2.1...v0.3.0)

#### Security

This release fixes **CVE-2026-17106** / **[GHSA-hfg8-hc9c-6c3h](https://github.com/moby/go-archive/security/advisories/GHSA-hfg8-hc9c-6c3h)**, where a crafted tar archive could use links to cause extraction operations to create or overwrite files outside the intended destination directory.

The issue affected `Unpack`, `UnpackLayer`, `Untar`, `UntarUncompressed`, and the `ApplyLayer` helpers. Users should upgrade and avoid extracting untrusted archives with earlier versions.

#### What's Changed

- archive: harden tar extraction against path traversal [#&#8203;45](https://github.com/moby/go-archive/pull/45)
- archive: do not follow reparse points in chtimes [#&#8203;90](https://github.com/moby/go-archive/pull/90)
- archive: fix creation time updates on Windows [#&#8203;79](https://github.com/moby/go-archive/pull/79)
- archive: minor cleanups and godoc touch-up [#&#8203;87](https://github.com/moby/go-archive/pull/87)
- archive: RebaseArchiveEntries: fix archive path rebasing [#&#8203;43](https://github.com/moby/go-archive/pull/43)

#### Test and CI changes

- ci: enable dependabot for actions [#&#8203;81](https://github.com/moby/go-archive/pull/81)
- archive: make breakoutErr unwrap its cause [#&#8203;91](https://github.com/moby/go-archive/pull/91)
- archive: use filepath for filesystem paths in tests [#&#8203;80](https://github.com/moby/go-archive/pull/80)
- archive: use filepath for filesystem paths in tests [#&#8203;80](https://github.com/moby/go-archive/pull/80)

**Full Changelog**: <https://github.com/moby/go-archive/compare/v0.2.1...v0.3.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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/1160
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-08-10 20:46:33 +00:00
Max P.andsilverwind e178c03adc fix(cache): build job URLs on the address its runner registered (#1153)
The cache server built every URL it hands a job from its own listen address, so jobs whose runner reaches it through a reverse proxy were sent to the internal one. This covered the v1 `archiveLocation`, the v2 signed cache URLs and `ACTIONS_RESULTS_URL`.

Runners now register the address their jobs reach the server at, next to the instance URL they already send. The cache-server needs no configuration of its own, and runners that reach it differently each get their own correct address.

Closes https://gitea.com/gitea/runner/issues/1152

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1153
Reviewed-by: silverwind <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: Max P. <[email protected]>
2026-08-08 07:05:38 +00:00
Renovate Botandsilverwind b66433e667 fix(deps): update module github.com/go-git/go-git/v5 to v5.19.2 [security] (#1156)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) | `v5.19.1` → `v5.19.2` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgo-git%2fgo-git%2fv5/v5.19.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgo-git%2fgo-git%2fv5/v5.19.1/v5.19.2?slim=true) |

---

### go-git: Worktree operations may follow symlinks
[CVE-2026-71556](https://nvd.nist.gov/vuln/detail/CVE-2026-71556) / [GHSA-hc8v-wwc9-vgxm](https://github.com/advisories/GHSA-hc8v-wwc9-vgxm)

<details>
<summary>More information</summary>

#### Details
##### Impact

A symlink traversal issue in `go-git` could allow worktree operations to modify files outside the intended worktree path.

The `worktreeFilesystem` wrapper rejected dangerous path strings, including paths containing `.git`, parent-directory components, or control characters. However, it did not prevent filesystem operations from following symbolic links that were already present in the worktree.

As a result, a path that is safe when evaluated as a string could still resolve into the repository's Git metadata directory. For example, if `s` is a symbolic link to `.git`, writing to `s/config` would modify `.git/config`.

A symbolic link at the final path component could also be followed. For example, if `s` points directly to `.git/config`, opening `s` for writing with truncation could overwrite the repository configuration.

Exploitation requires an attacker to be able to introduce or control a symbolic link in the worktree and cause the application to perform a write through that path.

Applications using `storage/memory` for their Storer, or `go-billy/memfs` for their `Worktree`, are not affected by this vulnerability.

##### Patches

The issue has been addressed by making the worktree filesystem wrapper a symlink-safe boundary.

Worktree operations now reject paths where an existing symbolic link in any path component could cause the operation to escape the intended worktree location, including symbolic links at the final component.

Users of filesystem-backed worktrees should upgrade to a patched version.

##### Credits

Thanks to @&#8203;kodareef5 for reporting this issue and working with the go-git security team toward its resolution. 🥇
We would also like to thank @&#8203;HughLewis20, who independently reported the same issue while a fix was already in progress.

#### Severity
- CVSS Score: 7.1 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L`

#### References
- [https://github.com/go-git/go-git/security/advisories/GHSA-hc8v-wwc9-vgxm](https://github.com/go-git/go-git/security/advisories/GHSA-hc8v-wwc9-vgxm)
- [https://github.com/go-git/go-git/commit/008a78f2dd86f52544ddff8b8e8ddeecdf3f7aab](https://github.com/go-git/go-git/commit/008a78f2dd86f52544ddff8b8e8ddeecdf3f7aab)
- [https://github.com/go-git/go-git/commit/661d1c7f101d34e002a3cfcf8dbea5b7421d07ac](https://github.com/go-git/go-git/commit/661d1c7f101d34e002a3cfcf8dbea5b7421d07ac)
- [https://github.com/go-git/go-git](https://github.com/go-git/go-git)
- [https://github.com/go-git/go-git/releases/tag/v5.19.2](https://github.com/go-git/go-git/releases/tag/v5.19.2)
- [https://github.com/go-git/go-git/releases/tag/v6.0.0-alpha.5](https://github.com/go-git/go-git/releases/tag/v6.0.0-alpha.5)

This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-hc8v-wwc9-vgxm) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### go-git: Malicious reference names may modify files outside the reference storage
[CVE-2026-71557](https://nvd.nist.gov/vuln/detail/CVE-2026-71557) / [GHSA-qgq7-7hm3-q39j](https://github.com/advisories/GHSA-qgq7-7hm3-q39j)

<details>
<summary>More information</summary>

#### Details
##### Impact
A path traversal issue in `go-git` could allow malicious reference names to access files outside the repository's intended reference storage.

Loose references are stored under `.git/<reference-name>`. The reference name was previously used as a path without verifying that the resolved path remained within the reference storage. A name such as `refs/heads/../../config` could therefore resolve to unrelated repository metadata such as `.git/config` or `.git/HEAD`.

A malicious Git server could advertise such a reference name. The name may also survive refspec mapping; for example, it could be mapped to `refs/remotes/origin/../../config` during a clone or fetch operation.

This vulnerability affects filesystem-backed repositories using the `storage/filesystem` package and its `dotgit` reference storage. Users relying exclusively on the in-memory storage implementation, `storage/memory`, are not affected, because reference names are not resolved as filesystem paths.

Exploitation requires an application using `go-git` with filesystem-backed storage to interact with a malicious Git server or otherwise process attacker-controlled reference names.

##### Patches
The issue has been addressed by validating reference names at the `dotgit` storage entry points and rejecting names whose resolved paths could escape the reference storage.

Users of filesystem-backed storage should upgrade to a patched version.

##### Workarounds
Applications that exclusively use `storage/memory` are not affected and do not require a workaround for this vulnerability.

For applications using filesystem-backed storage, avoid cloning from or fetching from untrusted Git servers until an upgrade is possible.

Applications that directly construct or process reference names may also validate them before passing them to filesystem-backed `go-git` storage. Application-level validation should only be considered a temporary mitigation and does not replace upgrading to a patched version.

##### References
- Fixes:
  - https://github.com/go-git/go-git/pull/2247
  - https://github.com/go-git/go-git/pull/2254

##### Credits

Thanks to @&#8203;Saku0512 for reporting this issue and @&#8203;Sahana2524 for proposing the initial fix. 🙇

#### Severity
- CVSS Score: 6.3 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:H/A:L`

#### References
- [https://github.com/go-git/go-git/security/advisories/GHSA-qgq7-7hm3-q39j](https://github.com/go-git/go-git/security/advisories/GHSA-qgq7-7hm3-q39j)
- [https://github.com/go-git/go-git/pull/2247](https://github.com/go-git/go-git/pull/2247)
- [https://github.com/go-git/go-git/pull/2254](https://github.com/go-git/go-git/pull/2254)
- [https://github.com/go-git/go-git/commit/4a0e66d555de5f9a30c31e2df64f445f42bd01e7](https://github.com/go-git/go-git/commit/4a0e66d555de5f9a30c31e2df64f445f42bd01e7)
- [https://github.com/go-git/go-git/commit/da9f7d8a0e98b475600177348d6ece384a370f36](https://github.com/go-git/go-git/commit/da9f7d8a0e98b475600177348d6ece384a370f36)
- [https://github.com/go-git/go-git](https://github.com/go-git/go-git)
- [https://github.com/go-git/go-git/releases/tag/v5.19.2](https://github.com/go-git/go-git/releases/tag/v5.19.2)
- [https://github.com/go-git/go-git/releases/tag/v6.0.0-alpha.5](https://github.com/go-git/go-git/releases/tag/v6.0.0-alpha.5)

This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-qgq7-7hm3-q39j) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>go-git/go-git (github.com/go-git/go-git/v5)</summary>

### [`v5.19.2`](https://github.com/go-git/go-git/releases/tag/v5.19.2)

[Compare Source](https://github.com/go-git/go-git/compare/v5.19.1...v5.19.2)

#### What's Changed

- build: Update module golang.org/x/crypto to v0.52.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2150](https://github.com/go-git/go-git/pull/2150)
- build: Update module github.com/go-git/go-git/v5 to v5.19.1 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2141](https://github.com/go-git/go-git/pull/2141)
- build: Update module golang.org/x/net to v0.55.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2152](https://github.com/go-git/go-git/pull/2152)
- git: Worktree: Add stores index entires with backslashes on Windows by [@&#8203;joshblum](https://github.com/joshblum) in [#&#8203;2262](https://github.com/go-git/go-git/pull/2262)
- storage: dotgit, reject path traversal in reference names by [@&#8203;pjbgf](https://github.com/pjbgf) in [#&#8203;2254](https://github.com/go-git/go-git/pull/2254)
- build: Update module golang.org/x/net to v0.56.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2267](https://github.com/go-git/go-git/pull/2267)
- build: Update module golang.org/x/text to v0.39.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2268](https://github.com/go-git/go-git/pull/2268)
- \[v5] git: worktree, make the filesystem wrapper a symlink-safe boundary by [@&#8203;pjbgf](https://github.com/pjbgf) in [#&#8203;2277](https://github.com/go-git/go-git/pull/2277)

**Full Changelog**: <https://github.com/go-git/go-git/compare/v5.19.1...v5.19.2>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- 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-->

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1156
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-08-08 00:35:53 +00:00
Lunny Xiao a8dcd5b67c refactor: move act/model and act/exprparser to actionslib (#1143)
Gitea needs the workflow model and the expression evaluator to parse workflows and to build the task payload this runner consumes, so today it depends on `gitea.com/gitea/runner` just for `act/model` and `act/exprparser`. Both packages now live in `gitea.dev/actionslib` (`pkg/model`, `pkg/exprparser`), the module both sides already share, and this repository consumes them from there.

### Changes

- `act/model` and `act/exprparser` are deleted, all imports point at `gitea.dev/actionslib/pkg/...`.
- New `act/ghcontext` package: the `GithubContext` helpers that need a git checkout on disk (`SetRef`, `SetSha`, `SetRepositoryAndOwner`) are runner only and would drag a git client plus the act context logger into the shared module, so they stay here as functions, with their tests. Only caller is `RunContext.getGithubContext`.
- `act/common.CartesianProduct` moved to the shared model package, `act/model` was its only user.
- `act/model/testdata/container-volumes` moved to `act/runner/testdata/container-volumes`, its only user is `runner_test.go`.
- `internal/pkg/client.UUIDHeader` / `TokenHeader` now alias `pkg/protocol`, so the header names cannot drift apart from Gitea.

### Notes

- No behaviour change intended: the moved files are unchanged apart from the import paths and the split described above.
- `go.mod` depends on the released `gitea.dev/actionslib v0.7.0`, which carries both https://gitea.com/gitea/actionslib/pulls/11 and the `model.UsesHash` port in https://gitea.com/gitea/actionslib/pulls/14 that `main` needs after https://gitea.com/gitea/runner/pulls/1150.
- Verified with `go build ./...`, `go vet ./...` and `go test ./act/... ./internal/...`; the docker based `act/runner` integration tests (`TestRunEvent`, `TestRunMatrixWithUserDefinedInclusions`) fail identically with and without this change in my environment.

Assisted-by: Codet:GPT-5.1-Codex
Reviewed-on: https://gitea.com/gitea/runner/pulls/1143
Reviewed-by: silverwind <[email protected]>
2026-08-08 00:29:15 +00:00
silverwind da9b559fb5 chore: revert docker 29.7.0 workaround (#1155)
Revert https://gitea.com/gitea/runner/pulls/1130. Docker 29.7.1 fixed both regressions it worked around, https://github.com/moby/moby/pull/53261 and https://github.com/moby/moby/pull/53260, so only 29.7.0 still needs it.

Verified live with a relative and an absolute `/var/run` symlink: without the workaround the copy passes on 29.4.0, 29.6.2 and 29.7.1, and fails on 29.7.0 alone.

Fixes: https://gitea.com/gitea/runner/issues/1131
Reviewed-on: https://gitea.com/gitea/runner/pulls/1155
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-07 19:19:43 +00:00
silverwind 9dd9204937 feat: support $/ prefix in action uses: (#1150)
Accepts GitHub's `$/` self-repository prefix in a step `uses:`. It resolves to the repository holding the file that wrote the `uses:`, at the ref being run, with no checkout. Inside a composite action that is the enclosing action, otherwise the workflow's own repo and commit.

The action cache is keyed on the resolved reference for these, because the same `$/x` names a different action per enclosing repository.

Related PR for job-level support: https://github.com/go-gitea/gitea/pull/38822

Reviewed-on: https://gitea.com/gitea/runner/pulls/1150
Reviewed-by: Zettat123 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-07 17:36:58 +00:00
24c13a1fd0 chore: revert 4c2ab943a8 (#1148)
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/[email protected]: 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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1136
Reviewed-by: techknowlogick <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1148
Reviewed-by: Zettat123 <[email protected]>
2026-08-06 21:50:21 +00:00
silverwindandbircni 4c2ab943a8 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/[email protected]: 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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1136
Reviewed-by: techknowlogick <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-06 20:05:42 +00:00
silverwindandbircni 20497aaf4f fix: evaluate each ${{ }} part on its own (#1146)
Every `${{ }}` part was spliced as raw text into a synthesized `format('...', <raw>)` call and re-parsed, so unbalanced parentheses restructured the whole expression:

```yaml
run: echo ${{ 1) && (2 }}      # panicked with "did not evaluate to a string"
if: ${{ 1 }} ${{ 0) && (0 }}   # silently skipped the step
```

One scanner shaped like GitHub's template reader now splits every value, and each part is evaluated on its own, so nothing builds an expression out of text.

An empty input to `Evaluate` asked `success()` whatever the caller requested, so a post step running under `always()` asked the wrong question.

Same fix as https://github.com/go-gitea/gitea/pull/38754 on the Gitea side.

Written by Claude Opus 5.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1146
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-06 16:05:39 +00:00
silverwind 09b643bc14 fix: trim whitespace from register inputs (#1147)
Secrets often carry a trailing newline, for example from `echo "token" | base64`, which made `register --no-interactive` fail with `runner registration token not found`. The interactive path already trims typed values, this aligns the flag path.

Related to: https://gitea.com/gitea/runner/issues/727

Reviewed-on: https://gitea.com/gitea/runner/pulls/1147
Reviewed-by: techknowlogick <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-06 15:50:16 +00:00
silverwind 1d6c6ffef9 enhance: improve config, comment out values in example file (#1145)
`config.example.yaml` now has every value commented out, as gitea's `app.example.ini` does, so it documents each option with its default instead of imposing it. Copying it no longer pins values the runner would otherwise pick, and a changed default reaches configs that never named the option.

`config init` writes the file to configure: a header comment and no option, so every option keeps its default. It refuses to overwrite an existing config without `--force`, and writes `config.yaml` in the working directory when `-c` is absent.

`config set`, `add` and `remove` keep such a file readable. Comments go back to the indentation they were written at, which the encoder drops for a comment block that has no key below it, and a file of only comments keeps its text instead of being emptied by the first edit.

Also rewrote the config docs sections for clarity.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1145
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-06 05:15:32 +00:00
silverwind 70387cca44 chore: align go version handling with gitea (#1144)
Aligns Go version handling with gitea, see https://github.com/go-gitea/gitea/pull/38559. `toolchain` names the build version, `go` stays the minimum.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1144
Reviewed-by: techknowlogick <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-05 22:50:11 +00:00
bircniandsilverwind 68547886a5 feat: wait for healthy services and fill the job context (#1107)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1107
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-08-05 19:46:43 +00:00
8700adc933 feat: config command to edit config files (#1140)
Changing a setting after `generate-config` meant hand-editing YAML, which is awkward in provisioning scripts. `config` now edits an existing file in place:

```bash
./gitea-runner -c config.yaml config set runner.capacity 4
./gitea-runner -c config.yaml config set runner.envs.MY_VAR value
./gitea-runner -c config.yaml config add runner.labels 'ubuntu:docker://node:22'
./gitea-runner -c config.yaml config remove runner.labels 'ubuntu:docker://node:22'
./gitea-runner -c config.yaml config get runner.labels
```

Edits go through the YAML node tree, so comments, key order and the blank lines between top-level sections survive — a test asserts that appending a label to `config.example.yaml` changes nothing but the added line. Keys are resolved by reflecting over the `Config` struct's yaml tags, so an unknown key or a value of the wrong type is rejected before the file is touched. The write is atomic and keeps the file's symlink, owner, mode and line endings.

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1140
Reviewed-by: silverwind <[email protected]>
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-08-05 16:48:25 +00:00
bircniandsilverwind b70ff6893a feat: gate set-env/add-path and render annotation locations (#1109)
`::set-env::` and `::add-path::` let a step rewrite the environment of every later step from its own output, which the runner honoured silently. They are now refused, as GitHub has done since 2020, and `ACTIONS_ALLOW_UNSECURE_COMMANDS` opts back in per step or job. Support for that variable is new here too, and is the only opt-in, matching GitHub rather than adding a runner config key on top.

Annotations keep their source location: Gitea has no annotation store and its web UI strips command properties, so `::error file=main.go,line=12::msg` is rendered as `::error::main.go:12: msg`.

`DEVELOPMENT.md` writes down the log line encoding rules this relies on.

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1109
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-08-05 16:43:17 +00:00
silverwind 3618385b28 fix: serve the whole results service from the cache server (#1141)
`ACTIONS_RESULTS_URL` names one origin serving every `github.actions.results.api.v1` service. Gitea serves the artifact half and this runner the cache half, so announcing `ACTIONS_CACHE_SERVICE_V2` while that URL pointed at Gitea was a promise the environment could not keep, and `docker buildx` posted its cache calls at Gitea and got a 404.

The cache server now forwards the artifact half to the instance each job registers with, so it is the whole results service and jobs are pointed at it. The announcement follows, and the bundle patch follows the cache URL instead.

Also fixes three things no JavaScript client reached: camelCase in the v2 responses where the Go clients read proto names, the missing `x-ms-request-id` on blob uploads that panics buildkit, and `cache.external_server` passed through without the trailing slash the v1 client concatenates onto.

Tests run the real actions against the services they look for: `actions/cache` over both API versions, the artifact actions up and back down through the forwarding, and `setup-node`. The regression itself is covered by asserting that whatever a job is handed as `ACTIONS_RESULTS_URL` answers a cache service call.

Fixes https://gitea.com/gitea/runner/issues/1139

Reviewed-on: https://gitea.com/gitea/runner/pulls/1141
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-03 16:41:53 +00:00
Renovate Bot 55a625f733 chore(deps): update dependencies (#1138)
Co-authored-by: Renovate Bot <[email protected]>
2026-08-03 09:22:17 +00:00
silverwind 47366f8f34 fix: coerce every expression value kind to a string and drop the format() panic (#1135)
`coerceToString` covered only a few kinds and returned the unconverted `reflect.Value` for the rest, so callers rendered contexts as `<*model.GithubContext Value>` and mangled sized integers and floats the same way. It now returns a string, which makes that placeholder unrepresentable, and is exported as `CoerceToString` so Gitea can drop its own diverging copy. It also takes an already reflected value, so internal callers need no conversion and no guard against the zero `Value`.

`format()` panicked whenever a lone `}` was followed by anything other than another `}`. Only a trailing `}` reached the existing unmatched-brace check, so an expression such as `format('a}b')`, which any workflow can write, took the process down instead. It now returns that same error.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1135
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-02 22:18:20 +00:00
silverwind b7aeda6e7f docs: sync AGENTS.md with gitea/gitea (#1134)
Carries over the applicable rules from https://gitea.com/gitea/gitea `AGENTS.md`, including the Conventional Commits and `make lint-go-windows` requirements this repo already enforces but never documented for agents. Swaps `Co-Authored-By` for the `Assisted-by` trailer.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1134
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-02 20:29:08 +00:00
silverwind aced51b4d5 chore: drop Masterminds/semver and pkg/errors, re-sync the docker/cli copies (#1132)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-02 17:36:31 +00:00
silverwind 34bfa19150 fix: resolve symlinked container paths before building tar entries (#1130)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-07-31 16:09:27 +00:00
bircni 96d9f491db fix: strip leading slash from mkdir tarball in CopyTarStream (#1129)
`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 <[email protected]>
2026-07-31 15:11:44 +00:00
bircni 14ec00b66e fix!: strip host-escape container options when privileged mode is disabled (#1058)
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 <[email protected]>
2026-07-31 12:43:19 +00:00
bircni 68c6a5b4f1 feat: mask secrets that reach the log in an encoded form (#1108)
Only the verbatim value of a secret was masked, so a secret leaked through an action that serialized it stayed readable: `toJSON(secrets)` escapes it, an Authorization header carries it base64-encoded, a URL percent-encodes it. Each secret and `::add-mask::` value is now masked in those forms too, matching the value encoders of GitHub's runner. Encodings that leave the value unchanged are skipped, so a plain token still costs a single replacement pair. Includes regression tests.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1108
Reviewed-by: Zettat123 <[email protected]>
2026-07-31 12:35:10 +00:00
bircniandZettat123 0cd0e52a24 fix!: guard against two runner processes sharing one runner file (#1099)
Starting two runner daemons with the same `.runner` file makes both present an
identical UUID+token, so Gitea treats them as one runner and they cancel each
other's jobs. This adds a non-blocking advisory lock on a sibling
`<runner-file>.lock`: the daemon (and `register`) acquire it at startup, and a
second process on the same host fails fast with a clear error instead of silently
interfering. The OS releases the lock when the process exits — including a hard
kill — so no stale lock is left behind. Legitimate multi-runner setups are
unaffected since each already uses its own `runner.file`.

Note: this covers the common single-host case; two hosts sharing a copied
`.runner` (e.g. over NFS) would still need server-side detection in Gitea.

---------

Co-authored-by: Zettat123 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1099
Reviewed-by: Zettat123 <[email protected]>
2026-07-31 12:15:04 +00:00
bircniandsilverwind 47d5b5ad03 feat!: add cache service v2, add toolkit patches (#1110)
Serves `github.actions.results.api.v1.CacheService` next to the v1 cache API, sharing its store, plus the subset of the Azure blob protocol the toolkit uploads with. On by default via `cache.v2`, and works with `external_server`.

Clients reach it through two edits in the action's own bundle: the GHES check is opened, and the cache service URL is taken from `ACTIONS_CACHE_URL`.

The same GHES check is what makes the stock `actions/upload-artifact` and `download-artifact` abort on Gitea. Opening it makes them work without the `gitea-upload-artifact` fork, from `[email protected]` on.

Verified against 118 real bundles, every major version of 16 actions: 92 patched, the rest deliberately left alone, and every patched bundle checked with `node --check`. Also end to end against pinned `actions/[email protected]` with an unreachable results URL, so only the patch can make the cache work.

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1110
Reviewed-by: silverwind <[email protected]>
2026-07-31 12:08:44 +00:00
Renovate Bot 2398d4a527 fix(deps): update module github.com/ulikunitz/xz to v0.5.15 [security] (#1127)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) | `v0.5.10` → `v0.5.15` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fulikunitz%2fxz/v0.5.15?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fulikunitz%2fxz/v0.5.10/v0.5.15?slim=true) |

---

### github.com/ulikunitz/xz leaks memory when decoding a corrupted multiple LZMA archives
[CVE-2025-58058](https://nvd.nist.gov/vuln/detail/CVE-2025-58058) / [GHSA-jc7w-c686-c4v9](https://github.com/advisories/GHSA-jc7w-c686-c4v9) / [GO-2025-3922](https://pkg.go.dev/vuln/GO-2025-3922)

<details>
<summary>More information</summary>

#### Details
##### Summary

It is possible to put data in front of an LZMA-encoded byte stream without detecting the situation while reading the header. This can lead to increased memory consumption because the current implementation allocates the full decoding buffer directly after reading the header. The LZMA header doesn't include a magic number or has  a checksum to detect such an issue according to the [specification](https://github.com/jljusten/LZMA-SDK/blob/master/DOC/lzma-specification.txt).

Note that the code recognizes the issue later while reading the stream, but at this time the memory allocation has already been done.

##### Mitigations

The release v0.5.15 includes following mitigations:

- The ReaderConfig DictCap field is now interpreted as a limit for the dictionary size.
- The default is 2 Gigabytes - 1 byte (2^31-1 bytes).
- Users can check with the [Reader.Header] method what the actual values are in  their LZMA files and set a smaller limit using ReaderConfig.
- The dictionary size will not exceed the larger of the file size and the minimum dictionary size. This is another measure to prevent huge memory allocations for the dictionary.
- The code supports stream sizes only up to a pebibyte (1024^5).

Note that the original v0.5.14 version had a compiler error for 32 bit platforms, which has been fixed by v0.5.15.

##### Methods affected

Only software that uses [lzma.NewReader](https://pkg.go.dev/github.com/ulikunitz/xz/lzma#NewReader) or [lzma.ReaderConfig.NewReader](https://pkg.go.dev/github.com/ulikunitz/xz/lzma#ReaderConfig.NewReader) is affected. There is no issue for software using the xz functionality.

I thank  @&#8203;GregoryBuligin for his report, which is provided below.

##### Summary
When unpacking a large number of LZMA archives, even in a single goroutine, if the first byte of the archive file is 0 (a zero byte added to the beginning), an error __writeMatch: distance out of range__ occurs. Memory consumption spikes sharply, and the GC clearly cannot handle this situation.

##### Details
Judging by the error  __writeMatch: distance out of range__, the problems occur in the code around this function.
https://github.com/ulikunitz/xz/blob/c8314b8f21e9c5e25b52da07544cac14db277e89/lzma/decoderdict.go#L81

##### PoC
Run a function similar to this one in 1 or several goroutines on a multitude of LZMA archives that have a 0 (a zero byte) added to the beginning.
```
const ProjectLocalPath = "some/path"
const TmpDir = "tmp"

func UnpackLZMA(lzmaFile string) error {
	file, err := os.Open(lzmaFile)
	if err != nil {
		return err
	}
	defer file.Close()

	reader, err := lzma.NewReader(bufio.NewReader(file))
	if err != nil {
		return err
	}

	tmpFile, err := os.CreateTemp(TmpDir, TmpLZMAPrefix)
	if err != nil {
		return err
	}
	defer func() {
		tmpFile.Close()
		_ = os.Remove(tmpFile.Name())
	}()

	sha256Hasher := sha256.New()
	multiWriter := io.MultiWriter(tmpFile, sha256Hasher)

	if _, err = io.Copy(multiWriter, reader); err != nil {
		return err
	}

	unpackHash := hex.EncodeToString(sha256Hasher.Sum(nil))
	unpackDir := filepath.Join(
		ProjectLocalPath, unpackHash[:2],
	)
	_ = os.MkdirAll(unpackDir, DirPerm)

	unpackPath := filepath.Join(unpackDir, unpackHash)

	return os.Rename(tmpFile.Name(), unpackPath)
}
```

##### Impact
Servers with a small amount of RAM that download and unpack a large number of unverified LZMA archives

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L`

#### References
- [https://github.com/ulikunitz/xz/security/advisories/GHSA-jc7w-c686-c4v9](https://github.com/ulikunitz/xz/security/advisories/GHSA-jc7w-c686-c4v9)
- [https://nvd.nist.gov/vuln/detail/CVE-2025-58058](https://nvd.nist.gov/vuln/detail/CVE-2025-58058)
- [https://github.com/ulikunitz/xz/commit/88ddf1d0d98d688db65de034f48960b2760d2ae2](https://github.com/ulikunitz/xz/commit/88ddf1d0d98d688db65de034f48960b2760d2ae2)
- [https://github.com/ulikunitz/xz](https://github.com/ulikunitz/xz)

This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-jc7w-c686-c4v9) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Memory leaks when decoding a corrupted multiple LZMA archives in github.com/ulikunitz/xz
[CVE-2025-58058](https://nvd.nist.gov/vuln/detail/CVE-2025-58058) / [GHSA-jc7w-c686-c4v9](https://github.com/advisories/GHSA-jc7w-c686-c4v9) / [GO-2025-3922](https://pkg.go.dev/vuln/GO-2025-3922)

<details>
<summary>More information</summary>

#### Details
Memory leaks when decoding a corrupted multiple LZMA archives in github.com/ulikunitz/xz

#### Severity
Unknown

#### References
- [https://github.com/ulikunitz/xz/security/advisories/GHSA-jc7w-c686-c4v9](https://github.com/ulikunitz/xz/security/advisories/GHSA-jc7w-c686-c4v9)
- [https://github.com/ulikunitz/xz/commit/88ddf1d0d98d688db65de034f48960b2760d2ae2](https://github.com/ulikunitz/xz/commit/88ddf1d0d98d688db65de034f48960b2760d2ae2)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2025-3922) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Release Notes

<details>
<summary>ulikunitz/xz (github.com/ulikunitz/xz)</summary>

### [`v0.5.15`](https://github.com/ulikunitz/xz/compare/v0.5.14...v0.5.15)

[Compare Source](https://github.com/ulikunitz/xz/compare/v0.5.14...v0.5.15)

### [`v0.5.14`](https://github.com/ulikunitz/xz/compare/v0.5.13...v0.5.14)

[Compare Source](https://github.com/ulikunitz/xz/compare/v0.5.13...v0.5.14)

### [`v0.5.13`](https://github.com/ulikunitz/xz/compare/v0.5.12...v0.5.13)

[Compare Source](https://github.com/ulikunitz/xz/compare/v0.5.12...v0.5.13)

### [`v0.5.12`](https://github.com/ulikunitz/xz/compare/v0.5.11...v0.5.12)

[Compare Source](https://github.com/ulikunitz/xz/compare/v0.5.11...v0.5.12)

### [`v0.5.11`](https://github.com/ulikunitz/xz/compare/v0.5.10...v0.5.11)

[Compare Source](https://github.com/ulikunitz/xz/compare/v0.5.10...v0.5.11)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- 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/1127
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-07-30 17:05:53 +00:00
silverwindandbircni b7a3bf98bc fix: stop leaking per-job docker networks (#1124)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1124
Reviewed-by: bircni <[email protected]>
2026-07-30 16:25:47 +00:00
silverwind da4037899a chore: add renovate markers to Makefile tool packages (#1126)
The shared renovate config at https://gitea.com/gitea/renovate-config already updates `_PACKAGE` variables carrying a trailing `# renovate: datasource=` marker, so add them here.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1126
Co-authored-by: silverwind <[email protected]>
2026-07-30 16:09:28 +00:00
silverwind e4fe49dba4 chore: scope fmt-check to Go sources (#1125)
`fmt-check` runs `make fmt` and then diffs the whole working tree, so any unrelated uncommitted file makes it fail with `Please run 'make fmt' and commit the result` even when the formatter changed nothing.

Restrict the diff to the files the formatter can touch, matching what [gitea's Makefile](https://github.com/go-gitea/gitea/blob/main/Makefile) does.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1125
Reviewed-by: techknowlogick <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-07-30 13:06:28 +00:00
41c72216bf feat: propagate proxy variables to jobs, services and builds (#1112)
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1112
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-07-30 08:15:48 +00:00
silverwind 3f7fd16ea1 fix: fix panics, enable the forcetypeassert lint (#1123)
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.

https://gitea.com/gitea/runner/commit/48576ab3e5c485af5a21ee6d21888a049ecef42b 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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-07-30 07:09:53 +00:00
silverwindandbircni 0192861155 fix: allow relocating the tool cache and mounting over runner paths (#1122)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1122
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-07-29 21:32:28 +00:00
bircniandsilverwind e6c7ba3a15 feat: add job hooks (#1111)
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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1111
Reviewed-by: silverwind <[email protected]>
2026-07-29 19:16:24 +00:00
silverwind 61f0cfa951 test: speed up (#1121)
Full suite 178s → 96s, and a run's log 12MB → 2KB. `act/runner` was 177s of the 178s, serialised behind one docker daemon.

- Its fixtures now run in parallel, bounded by a slot count instead of `go test -parallel`, with a per-test container name prefix and a pinned `MaxParallel`.
- Fixtures asserting a job failure leaked their container and network (`AutoRemove` was at the act-CLI default), filling the daemon's address pool over time.
- Replaced sleeps used as synchronisation in the parallel-executor and cache-handler tests.
- Dropped duplicate coverage: two files re-testing `NewParallelExecutor`, a test asserting on its own semaphore, and `TestDockerActionForcePullForceRebuild`, whose config `runTest` discarded.
- `fmt-check`/`security-check` move from `make test` to a `checks` target; `security-check` no longer installs `xgo` and `gxz`.
- Test flags follow gitea: `GOTEST_FLAGS ?= -race -timeout 20m -parallel 8`, with `-cover`/`-coverprofile` left in the target. Dropped `-v`, since a failing package still prints its full output without it.

Coverage unchanged at 73.4%.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1121
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-07-28 14:34:04 +00:00
418 changed files with 38066 additions and 22040 deletions

No files matched your search

+5
View File
@@ -45,8 +45,13 @@ cpu.out
/bin /bin
/dist /dist
/.tmp
/.git/hooks
/.git/logs
/.git/rr-cache
/.env /.env
/.runner /.runner
/.runner.lock
/config.yaml /config.yaml
/Dockerfile /Dockerfile
.DS_Store .DS_Store
+29
View File
@@ -0,0 +1,29 @@
name: go-setup
description: Set up go and restore its caches, which only the cache-seeder workflow saves
runs:
using: composite
steps:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
id: setup-go
with:
go-version-file: go.mod
check-latest: true
cache: false
- if: ${{ github.workflow == 'cache-seeder' }}
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
~/.cache/golangci-lint
key: go-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('go.mod', 'go.sum', '.golangci.yml', 'Makefile', '.gitea/actions/go-setup/action.yml', '.gitea/workflows/cache-seeder.yml') }}
- if: ${{ github.workflow != 'cache-seeder' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
~/.cache/golangci-lint
key: go-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('go.mod', 'go.sum', '.golangci.yml', 'Makefile', '.gitea/actions/go-setup/action.yml', '.gitea/workflows/cache-seeder.yml') }}
restore-keys: go-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-go.outputs.go-version }}-
+26
View File
@@ -0,0 +1,26 @@
name: cache-seeder
on:
workflow_dispatch:
push:
branches:
- main
paths:
- go.mod
- go.sum
- .golangci.yml
- Makefile
- .gitea/actions/go-setup/action.yml
- .gitea/workflows/cache-seeder.yml
jobs:
seed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.gitea/actions/go-setup
- run: make deps-tools lint build
- name: compile the test binaries without running them
run: |
make test GOTEST_FLAGS='-race -exec true'
go test -tags e2e -exec true ./e2e/...
+2 -2
View File
@@ -19,9 +19,9 @@ jobs:
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with: with:
node-version: 24 node-version: 26
- run: make lint-pr-title - run: make lint-pr-title
env: env:
PR_TITLE: ${{ github.event.pull_request.title }} PR_TITLE: ${{ github.event.pull_request.title }}
+17 -15
View File
@@ -11,7 +11,6 @@ on:
env: env:
DOCKER_ORG: gitea DOCKER_ORG: gitea
DOCKER_LATEST: nightly
jobs: jobs:
goreleaser: goreleaser:
@@ -20,11 +19,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
fetch-depth: 0 fetch-depth: 0
# Custom publishers (the R2 mirror below) run as the very last # Custom publishers (the R2 upload below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release # step of goreleaser's publish pipeline, after the Gitea release
# has already been created and every artifact already uploaded # has already been created. Fail here instead, before anything
# to S3. Fail here instead, before anything is built or # is built or published, if the R2 secrets are missing.
# published, if the R2 secrets are missing.
- name: check R2 configuration - name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config run: sh scripts/upload-r2.sh --check-config
env: env:
@@ -35,24 +33,28 @@ jobs:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
check-latest: true
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.PASSPHRASE }}
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
- name: goreleaser - name: goreleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
args: release --nightly args: release --nightly
env: env:
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
GORELEASER_FORCE_TOKEN: "gitea" GORELEASER_FORCE_TOKEN: "gitea"
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
release-image: release-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -79,13 +81,13 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
@@ -99,7 +101,7 @@ jobs:
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+9 -15
View File
@@ -12,11 +12,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
# Custom publishers (the R2 mirror below) run as the very last # Custom publishers (the R2 upload below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release # step of goreleaser's publish pipeline, after the Gitea release
# has already been created and every artifact already uploaded # has already been created. Fail here instead, before anything
# to S3. Fail here instead, before anything is built or # is built or published, if the R2 secrets are missing.
# published, if the R2 secrets are missing.
- name: check R2 configuration - name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config run: sh scripts/upload-r2.sh --check-config
env: env:
@@ -27,6 +26,7 @@ jobs:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
check-latest: true
- name: Import GPG key - name: Import GPG key
id: import_gpg id: import_gpg
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
@@ -35,17 +35,12 @@ jobs:
passphrase: ${{ secrets.PASSPHRASE }} passphrase: ${{ secrets.PASSPHRASE }}
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0 fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
- name: goreleaser - name: goreleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
args: release args: release
env: env:
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
@@ -74,7 +69,6 @@ jobs:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
env: env:
DOCKER_ORG: gitea DOCKER_ORG: gitea
DOCKER_LATEST: latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -82,13 +76,13 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
@@ -108,7 +102,7 @@ jobs:
suffix=${{ matrix.variant.tag_suffix }},onlatest=true suffix=${{ matrix.variant.tag_suffix }},onlatest=true
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+68 -16
View File
@@ -1,38 +1,46 @@
name: checks name: checks
on: on:
push:
branches:
- main
pull_request: pull_request:
workflow_dispatch:
env:
# The runner image ships a stale docker.io login; point docker at an empty config so
# image pulls go straight to anonymous instead of attempting (and failing) that auth
# first. The path must be a literal: the `runner` context is unavailable in workflow-level
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
# to ~/.docker with the stale credentials.
DOCKER_CONFIG: /tmp/docker-noauth
jobs: jobs:
lint: lint:
name: check and test name: check and test
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# The runner image ships a stale docker.io login; point docker at an empty config so
# image pulls go straight to anonymous instead of attempting (and failing) that auth
# first. The path must be a literal: the `runner` context is unavailable in job-level
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
# to ~/.docker with the stale credentials.
DOCKER_CONFIG: /tmp/docker-noauth
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - uses: ./.gitea/actions/go-setup
with:
go-version-file: 'go.mod'
- name: prepare anonymous docker config - name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json" run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`; # Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host # the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
# daemon retains them between runs, so this is usually a fast manifest re-check. # daemon retains them between runs, and their digest pins skip the registry when it has them.
- name: pre-pull test images - name: pre-pull test images
env:
TEST_JOB_IMAGE: node:26-bookworm-slim@sha256:bc0a2afd3b74ce12222fc8bab99271e66257f021078f5f4a5d687a77d280c1d9 # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:a3554c0d0b48177b1413c26e01f695334428020d9df01243fbae3e11cfd17417 # renovate: datasource=docker
run: | run: |
for img in node:24-bookworm-slim nginx:alpine; do for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done if [ "$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null)" != linux/amd64 ]; then
for attempt in 1 2 3; do
docker pull "$image" && break
[ "$attempt" = 3 ] || sleep 5
done
fi
docker tag "$image" "${image%@*}"
done done
- name: lint - name: lint
run: make lint run: make lint
- name: checks
run: make checks
- name: build - name: build
run: make build run: make build
- name: test - name: test
@@ -42,7 +50,51 @@ jobs:
# after `make test` so the images it needs are already present on the host daemon. # after `make test` so the images it needs are already present on the host daemon.
- name: test against dind image - name: test against dind image
run: make test-dind run: make test-dind
- name: test against podman
run: make test-dind TARGET=podman
- name: coverage report - name: coverage report
run: | run: |
make coverage-report make coverage-report
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY" cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
windows:
name: test on windows
runs-on: ubuntu-latest-kvm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.gitea/actions/go-setup
- name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
- name: windows vm cache key
id: windows-vm
run: echo "key=windows-vm-$(grep '^WINDOWS_TEST_IMAGE' Makefile | cat - scripts/test-windows.sh scripts/test-windows-setup.ps1 | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/windows-vm
key: ${{ steps.windows-vm.outputs.key }}
- name: test on windows
id: test-windows
run: WINDOWS_TEST_CACHE="$HOME/.cache/windows-vm" make test-windows
env:
WINDOWS_TEST_TIMEOUT: 45
- if: always() && steps.test-windows.outputs.save-cache == 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/windows-vm
key: ${{ steps.windows-vm.outputs.key }}
e2e:
name: gitea compatibility (${{ matrix.gitea_image }})
runs-on: ubuntu-latest
strategy:
matrix:
gitea_image:
- gitea/gitea:latest
- gitea/gitea:main-nightly
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.gitea/actions/go-setup
- name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
- name: e2e compatibility
run: make test-e2e E2E_GITEA_IMAGE=${{ matrix.gitea_image }}
+2
View File
@@ -1,7 +1,9 @@
/gitea-runner /gitea-runner
/gitea-runner.exe
.env .env
!/act/runner/testdata/secrets/.env !/act/runner/testdata/secrets/.env
.runner .runner
.runner.lock
coverage.txt coverage.txt
.tmp/ .tmp/
/config.yaml /config.yaml
+21 -10
View File
@@ -11,6 +11,7 @@ linters:
- dupl - dupl
- errcheck - errcheck
- forbidigo - forbidigo
- forcetypeassert
- gocheckcompilerdirectives - gocheckcompilerdirectives
- gocritic - gocritic
- goheader - goheader
@@ -36,12 +37,17 @@ linters:
rules: rules:
main: main:
deny: deny:
- pkg: io/ioutil
desc: use os or io instead
- pkg: golang.org/x/exp - pkg: golang.org/x/exp
desc: it's experimental and unreliable desc: it's experimental and unreliable
- pkg: github.com/pkg/errors - pkg: unsafe
desc: use builtin errors package instead desc: use typed APIs instead
logging:
files:
- $all
- "!**/act/common/logging/**"
deny:
- pkg: log/slog
desc: log through gitea.com/gitea/runner/act/common/logging
nolintlint: nolintlint:
allow-unused: false allow-unused: false
require-explanation: true require-explanation: true
@@ -49,8 +55,7 @@ linters:
gocritic: gocritic:
enabled-checks: enabled-checks:
- equalFold - equalFold
disabled-checks: disabled-checks: []
- ifElseChain
revive: revive:
severity: error severity: error
rules: rules:
@@ -74,10 +79,14 @@ linters:
- name: unexported-return - name: unexported-return
- name: var-declaration - name: var-declaration
- name: var-naming - name: var-naming
arguments:
- [] # AllowList - do not remove as args for the rule are positional and won't work without lists first
- [] # DenyList
- - skip-initialism-name-checks: true
staticcheck: staticcheck:
checks: checks:
- all - all
- -ST1005 testifylint: {}
usetesting: usetesting:
os-temp-dir: true os-temp-dir: true
perfsprint: perfsprint:
@@ -95,13 +104,14 @@ linters:
generated: lax generated: lax
presets: presets:
- comments - comments
- common-false-positives
- legacy
- std-error-handling - std-error-handling
rules: rules:
- linters: - linters:
- forbidigo - forbidigo
path: cmd path: cmd
- linters:
- forcetypeassert
path: _test\.go
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
@@ -118,7 +128,8 @@ formatters:
- blank - blank
- default - default
gofumpt: gofumpt:
extra-rules: true extra:
group-params: true
exclusions: exclusions:
generated: lax generated: lax
run: run:
+12 -41
View File
@@ -28,38 +28,16 @@ builds:
ignore: ignore:
- goos: darwin - goos: darwin
goarch: arm goarch: arm
- goos: darwin
goarch: ppc64le
- goos: darwin - goos: darwin
goarch: s390x goarch: s390x
- goos: windows
goarch: ppc64le
- goos: windows - goos: windows
goarch: s390x goarch: s390x
- goos: windows - goos: windows
goarch: arm goarch: arm
goarm: "5"
- goos: windows
goarch: arm
goarm: "6"
- goos: windows
goarch: arm
goarm: "7"
- goos: windows
goarch: arm64
- goos: freebsd
goarch: ppc64le
- goos: freebsd - goos: freebsd
goarch: s390x goarch: s390x
- goos: freebsd - goos: freebsd
goarch: arm goarch: arm
goarm: "5"
- goos: freebsd
goarch: arm
goarm: "6"
- goos: freebsd
goarch: arm
goarm: "7"
- goos: freebsd - goos: freebsd
goarch: arm64 goarch: arm64
flags: flags:
@@ -83,24 +61,12 @@ builds:
- cmd: sh .goreleaser.checksum.sh {{ .Path }} - cmd: sh .goreleaser.checksum.sh {{ .Path }}
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz - cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
blobs: # Uploads every release artifact to Cloudflare R2. The `blobs:` pipe
- # isn't usable here since it authenticates from the global AWS_* env
provider: s3 # with no per-entry credentials; `publishers:` supports per-entry
bucket: "{{ .Env.S3_BUCKET }}" # `env:` instead, so it's used to invoke scripts/upload-r2.sh once per
region: "{{ .Env.S3_REGION }}" # artifact. Custom publishers inherit almost nothing from the
directory: "gitea-runner/{{.Version}}" # environment, hence the explicit R2_* forwarding below.
extra_files:
- glob: ./**.xz
- glob: ./**.sha256
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
# parallel S3+R2 period (S3 will be removed once migration completes).
# A second `blobs:` entry is impossible here since the blob pipe
# authenticates from the global AWS_* env with no per-entry
# credentials; `publishers:` supports per-entry `env:` instead, so
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
# publishers inherit almost nothing from the environment, hence the
# explicit R2_* forwarding below.
# #
# This publisher fires 109 times for 73 distinct keys because # This publisher fires 109 times for 73 distinct keys because
# goreleaser's release pipe already registers `release.extra_files` # goreleaser's release pipe already registers `release.extra_files`
@@ -114,6 +80,7 @@ blobs:
publishers: publishers:
- name: cloudflare-r2 - name: cloudflare-r2
checksum: true checksum: true
signature: true
extra_files: extra_files:
- glob: ./**.xz - glob: ./**.xz
- glob: ./**.sha256 - glob: ./**.sha256
@@ -125,7 +92,7 @@ publishers:
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }} - R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
archives: archives:
- format: binary - formats: [binary]
name_template: "{{ .Binary }}" name_template: "{{ .Binary }}"
allow_different_binary_count: true allow_different_binary_count: true
@@ -134,6 +101,10 @@ checksum:
extra_files: extra_files:
- glob: ./**.xz - glob: ./**.xz
signs:
- artifacts: checksum
args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
snapshot: snapshot:
version_template: "{{ .Branch }}-devel" version_template: "{{ .Branch }}-devel"
+17 -8
View File
@@ -1,10 +1,19 @@
- Never assume, verify before claiming
- Use `make help` to find available development targets - Use `make help` to find available development targets
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them - PR descriptions: minimal, only what and why, no task lists or file listings
- Run `make tidy` after any `go.mod` changes - Reference issues and PRs by full URL, not by number
- Run single go unit tests with `go test -run '^TestName$' ./modulepath/` - Use Conventional Commits for commit messages and PR titles, plus the `enhance` type for user-facing enhancements
- Add the current year into the copyright header of new `.go` files - Add an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer to commit messages, never `Co-Authored-By` or `Signed-off-by`
- Ensure no trailing whitespace in edited files - Attribute agent authorship on one trailing line in issue and pull request comments, never as a PR description section
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates - Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
- Preserve existing code comments, do not remove or rewrite comments that are still relevant - Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
- Include authorship attribution in issue and pull request comments - Add the current year into the copyright header of new `.go` files
- Add `Co-Authored-By` lines to all commits, indicating name and model used - Read `DEVELOPMENT.md` for internals and conventions
- Ensure no trailing whitespace in edited files
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, and `make checks` for the non-lint source checks
- Lint what changed with `make lint-go`, and `make lint-go-windows` for Windows and platform-split files
- Fix the cause rather than disabling a linter or weakening a test. Where unavoidable, use the narrowest scope with a trailing comment giving the reason
- Run single go tests with `go test -run '^TestName$' ./modulepath/`. `make test` self-skips the integration tests without docker or network, `make test-dind` runs the daemon-facing tests against the built dind image
- Write the fewest, fastest tests covering the behavior, extending an existing one where possible. Prefer unit tests where logic is testable in isolation
- Wait on a deterministic condition rather than `sleep`
- Update the files under `docs/` when behavior documented there changes
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+79
View File
@@ -0,0 +1,79 @@
# Development
## Job log line format
Gitea stores one log row per line and its web UI decodes the payload, so getting the encoding
wrong never fails a test here, it only shows up in the browser.
**A row cannot contain a real newline.** `FormatLog` rewrites `\n` to a literal backslash-n and
truncates at 64 KiB on a byte boundary.
**The payload of a line starting with a recognised prefix is decoded**, with the escape set
depending on the prefix:
| prefix | decodes |
| --- | --- |
| `##[error]` `##[warning]` `##[notice]` `##[debug]` `##[group]` `##[endgroup]` `##[add-matcher]` | `%25` `%0D` `%0A` `%3B` `%5D` |
| `::error::` `::warning::` `::notice::` `::debug::` (with or without ` key=value` properties), `::group::` `::endgroup::` `::add-matcher::` | `%25` `%0D` `%0A` |
| `##[command]` `[command]`, or no recognised prefix | nothing |
### Rules
- **Emitting a command line?** Escape the payload with `runner.EscapeCommandData`. One escaper
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
- **Forwarding a command from step output?** Preserve its escaping unless masking changes it.
Decoding for display happens in the UI.
- **No prefix?** Do not escape, and split multi-line values into one row each.
- **Interpolating a secret?** Masking runs after escaping. Recognised command payloads are
normalised before masking to cover partially escaped values. Their original escaping is
preserved when no replacement occurs. `AppendSecretMasker` also registers encoded forms for
other output. Complete nonblank one-character values are masked, while one-byte fragments
of multiline values are skipped.
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command
handler folds an annotation's location into its decoded message and escapes the result again.
## Logging
Log through `act/common/logging`; the linter rejects importing `log/slog` elsewhere.
A job's logger comes from `common.Logger(ctx)`; code with no job uses the package functions
(`logging.Infof`, `logging.With`). Both take printf-style messages, and `With("key", value)` adds
fields.
- The process-wide logger masks the secrets of every live task, the job logger masks its own.
Going around either leaks them.
- The job logger's output can reach the job log workflow authors see, the process-wide logger's
stays on the runner host, so keep operator-only detail (host paths, internal addresses) off the
job logger.
- Libraries that log through slog's global logger are forwarded to the process-wide one, so they
follow its level and masking too. Avoid dependencies that log through logrus, nothing forwards it.
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
package functions.
## End-to-end compatibility tests
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
parallel. Outside a container it runs `go test` in a Linux container on its own network, as CI does,
so both take the same paths. `E2E_ARGS` passes extra flags, e.g. `make test-e2e E2E_ARGS='-run TestCompatibility/dispatch'`.
The `cli_*` scenarios build the binary and drive `register` and `daemon` as a user would, including `--once`,
`--labels` and shutdown with a running job. `failure_paths` covers failed, skipped and tolerated jobs, `job_timeout`
a job killed at the runner's deadline. `label_routing` and `capacity` cover scheduling, `reconnect` drops the
connection to Gitea through a proxy, and `host_mode` and `reusable_workflow` cover host labels and `workflow_call`.
`dispatch`, `failure_paths` and `reusable_workflow` run in every mode: through the daemon and `exec`, each on
Docker and Kubernetes.
The suite shares one Gitea, regular runner and k3s cluster. Cache and ephemeral scenarios use isolated
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
## Windows tests
`make test-windows` cross-compiles the test binaries, installs Windows Server 2025 Core in a
[dockur/windows](https://github.com/dockur/windows) QEMU VM on the docker host and runs them there.
The host must be amd64 and allow privileged containers. `WINDOWS_TEST_TIMEOUT` sets the minutes
allowed for installing Windows and running the tests, 165 by default. The VM uses KVM when the host
has `/dev/kvm`, otherwise Windows is emulated and much slower. Docker-backed tests skip in the VM
and the race detector is off, it needs cgo. `WINDOWS_TEST_CACHE` names a directory to keep the
installed Windows in, so later runs skip the install. CI runs this on every pull request.
+14 -7
View File
@@ -1,7 +1,7 @@
### BUILDER STAGE ### BUILDER STAGE
# #
# #
FROM golang:1.26-alpine3.23 AS builder FROM golang:1.27-alpine AS builder
# Do not remove `git` here, it is required for getting runner version when executing `make build` # Do not remove `git` here, it is required for getting runner version when executing `make build`
RUN apk add --no-cache make git RUN apk add --no-cache make git
@@ -12,19 +12,22 @@ ENV GOPROXY=${GOPROXY:-}
COPY . /opt/src/runner COPY . /opt/src/runner
WORKDIR /opt/src/runner WORKDIR /opt/src/runner
RUN make clean && make build RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
make clean && make build
### DIND VARIANT ### DIND VARIANT
# #
# #
FROM docker:29.6.2-dind AS dind FROM docker:29.9.3-dind AS dind
ARG VERSION=dev ARG VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner" LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}" LABEL org.opencontainers.image.version="${VERSION}"
RUN apk add --no-cache s6 bash git tzdata nftables # upgrade first: the docker base image can lag behind Alpine security fixes
RUN apk upgrade --no-cache && apk add --no-cache s6 bash git tzdata nftables
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY scripts/run.sh /usr/local/bin/run.sh COPY scripts/run.sh /usr/local/bin/run.sh
@@ -32,12 +35,14 @@ COPY scripts/s6 /etc/s6
VOLUME /data VOLUME /data
ENV TINI_SUBREAPER=1
ENTRYPOINT ["s6-svscan","/etc/s6"] ENTRYPOINT ["s6-svscan","/etc/s6"]
### DIND-ROOTLESS VARIANT ### DIND-ROOTLESS VARIANT
# #
# #
FROM docker:29.6.2-dind-rootless AS dind-rootless FROM docker:29.9.3-dind-rootless AS dind-rootless
ARG VERSION=dev ARG VERSION=dev
@@ -45,7 +50,8 @@ LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}" LABEL org.opencontainers.image.version="${VERSION}"
USER root USER root
RUN apk add --no-cache s6 bash git tzdata nftables # upgrade first: the docker base image can lag behind Alpine security fixes
RUN apk upgrade --no-cache && apk add --no-cache s6 bash git tzdata nftables
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY scripts/run.sh /usr/local/bin/run.sh COPY scripts/run.sh /usr/local/bin/run.sh
@@ -56,6 +62,7 @@ VOLUME /data
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
ENV TINI_SUBREAPER=1
USER rootless USER rootless
ENTRYPOINT ["s6-svscan","/etc/s6"] ENTRYPOINT ["s6-svscan","/etc/s6"]
@@ -70,7 +77,7 @@ ARG VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner" LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}" LABEL org.opencontainers.image.version="${VERSION}"
RUN apk add --no-cache tini bash git tzdata RUN apk upgrade --no-cache && apk add --no-cache tini bash git tzdata
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY scripts/run.sh /usr/local/bin/run.sh COPY scripts/run.sh /usr/local/bin/run.sh
+41 -80
View File
@@ -1,25 +1,25 @@
DIST := dist DIST := dist
EXECUTABLE := gitea-runner
DIST_DIRS := $(DIST)/binaries $(DIST)/release
GO ?= go GO ?= go
SHASUM ?= shasum -a 256 EXECUTABLE := gitea-runner$(shell $(GO) env GOEXE)
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" ) HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
XGO_VERSION := go-1.26.x
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]
LINUX_ARCHS ?= linux/amd64,linux/arm64 PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64 E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:b96ba465021b1a4c6a1c9e79220c2b9bfad0f29b09dbb528bbfb35411443a3a4 # renovate: datasource=docker
WINDOWS_ARCHS ?= windows/amd64 SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.2-k3s1@sha256:21ea6e0fac4e3930c2ce8aa40ddd41a8b18cb3e9a0222c030dc005e7e88c4332 # renovate: datasource=docker
WINDOWS_TEST_IMAGE ?= dockurr/windows:6.06@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea # renovate: datasource=docker
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*") GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
DOCKER_IMAGE ?= gitea/runner DOCKER_IMAGE ?= gitea/runner
DOCKER_TAG ?= nightly DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG) DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]2.2 GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]5.0 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.9.0 # renovate: datasource=go
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
STATIC ?= STATIC ?=
EXTLDFLAGS ?= EXTLDFLAGS ?=
@@ -30,22 +30,16 @@ endif
ifeq ($(HAS_GO), GO) ifeq ($(HAS_GO), GO)
GOPATH ?= $(shell $(GO) env GOPATH) GOPATH ?= $(shell $(GO) env GOPATH)
export PATH := $(GOPATH)/bin:$(PATH) export PATH := $(GOPATH)/bin:$(PATH)
CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766
CGO_CFLAGS ?= $(shell $(GO) env CGO_CFLAGS) $(CGO_EXTRA_CFLAGS)
endif endif
ifeq ($(OS), Windows_NT) ifeq ($(OS), Windows_NT)
GOFLAGS := -v -buildmode=exe GOFLAGS := -v -buildmode=exe
EXECUTABLE ?= $(EXECUTABLE).exe
GO_ENV_WINDOWS := set GOOS=windows&& GO_ENV_WINDOWS := set GOOS=windows&&
else ifeq ($(OS), Windows) else ifeq ($(OS), Windows)
GOFLAGS := -v -buildmode=exe GOFLAGS := -v -buildmode=exe
EXECUTABLE ?= $(EXECUTABLE).exe
GO_ENV_WINDOWS := set GOOS=windows&& GO_ENV_WINDOWS := set GOOS=windows&&
else else
GOFLAGS := -v GOFLAGS := -v
EXECUTABLE ?= $(EXECUTABLE)
GO_ENV_WINDOWS := GOOS=windows GO_ENV_WINDOWS := GOOS=windows
endif endif
@@ -94,22 +88,18 @@ go-check:
fi fi
.PHONY: fmt-check .PHONY: fmt-check
fmt-check: fmt fmt-check: ## check the Go code formatting
@diff=$$(git diff --color=always); \ $(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: deps-tools .PHONY: deps-tools
deps-tools: ## install tool dependencies deps-tools: ## install tool dependencies
$(GO) install $(GOLANGCI_LINT_PACKAGE) & \ $(GO) install $(GOLANGCI_LINT_PACKAGE) & \
$(GO) install $(GXZ_PACKAGE) & \
$(GO) install $(XGO_PACKAGE) & \
$(GO) install $(GOVULNCHECK_PACKAGE) & \ $(GO) install $(GOVULNCHECK_PACKAGE) & \
wait wait
.PHONY: checks
checks: tidy-check fmt-check security-check ## run the non-lint source checks
.PHONY: lint .PHONY: lint
lint: lint-go lint-go-windows ## lint everything lint: lint-go lint-go-windows ## lint everything
@@ -131,25 +121,25 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
@node ./tools/lint-pr-title.ts @node ./tools/lint-pr-title.ts
.PHONY: security-check .PHONY: security-check
security-check: deps-tools security-check:
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
.PHONY: tidy .PHONY: tidy
tidy: ## run go mod tidy tidy: ## run go mod tidy
$(eval GO_TOOLCHAIN := $(shell grep -Eo '^toolchain\s+go[0-9.]+' go.mod | cut -d' ' -f2))
$(GO) mod tidy $(GO) mod tidy
@# workaround https://github.com/golang/go/issues/75331: restore toolchain if tidy dropped it
.PHONY: tidy-check @if [ -n "$(GO_TOOLCHAIN)" ] && ! grep -qE '^toolchain\s' go.mod; then \
tidy-check: tidy $(GO) mod edit -toolchain=$(GO_TOOLCHAIN); \
@diff=$$(git diff --color=always -- go.mod go.sum); \
if [ -n "$$diff" ]; then \
echo "Please run 'make tidy' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi fi
.PHONY: tidy-check
tidy-check: ## check that go.mod and go.sum are tidy
$(GO) mod tidy -diff
.PHONY: test .PHONY: test
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network) test: ## test everything (integration tests self-skip without docker/network)
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1 @$(GO) test $(GOTEST_FLAGS) -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: coverage-report .PHONY: coverage-report
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
@@ -158,8 +148,18 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
@echo "Wrote .tmp/coverage.md" @echo "Wrote .tmp/coverage.md"
.PHONY: test-dind .PHONY: test-dind
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless) test-dind: ## run the daemon-facing tests against the built dind image or podman (TARGET=dind|dind-rootless|podman)
@./scripts/test-dind.sh $(TARGET) @PODMAN_TEST_IMAGE=$(PODMAN_TEST_IMAGE) ./scripts/test-dind.sh $(TARGET)
.PHONY: test-windows
test-windows: ## run the tests on Windows Server Core in a QEMU VM on the docker host
@GO=$(GO) WINDOWS_TEST_IMAGE=$(WINDOWS_TEST_IMAGE) ./scripts/test-windows.sh
E2E_CONCURRENCY ?= 8
.PHONY: test-e2e
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_K3S_IMAGE=$(E2E_K3S_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh $(E2E_ARGS)
.PHONY: install .PHONY: install
install: $(GOFILES) ## install the runner binary via `go install` install: $(GOFILES) ## install the runner binary via `go install`
@@ -175,45 +175,6 @@ $(EXECUTABLE): $(GOFILES)
deps-backend: ## install backend dependencies deps-backend: ## install backend dependencies
$(GO) mod download $(GO) mod download
.PHONY: release
release: release-windows release-linux release-darwin release-copy release-compress release-check ## build release artifacts
$(DIST_DIRS):
mkdir -p $(DIST_DIRS)
.PHONY: release-windows
release-windows: | $(DIST_DIRS)
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -buildmode exe -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(WINDOWS_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
ifeq ($(CI),true)
cp -r /build/* $(DIST)/binaries/
endif
.PHONY: release-linux
release-linux: | $(DIST_DIRS)
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(LINUX_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
ifeq ($(CI),true)
cp -r /build/* $(DIST)/binaries/
endif
.PHONY: release-darwin
release-darwin: | $(DIST_DIRS)
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w $(LDFLAGS)' -targets '$(DARWIN_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
ifeq ($(CI),true)
cp -r /build/* $(DIST)/binaries/
endif
.PHONY: release-copy
release-copy: | $(DIST_DIRS)
cd $(DIST); for file in `find . -type f -name "*"`; do cp $${file} ./release/; done;
.PHONY: release-check
release-check: | $(DIST_DIRS)
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "checksumming $${file}" && $(SHASUM) `echo $${file} | sed 's/^..//'` > $${file}.sha256; done;
.PHONY: release-compress
release-compress: | $(DIST_DIRS)
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PACKAGE) -k -9 $${file}; done;
.PHONY: docker .PHONY: docker
docker: ## build the docker image docker: ## build the docker image
if ! docker buildx version >/dev/null 2>&1; then \ if ! docker buildx version >/dev/null 2>&1; then \
+218 -158
View File
@@ -1,14 +1,26 @@
# Gitea Runner # Gitea Runner
Gitea Runner runs the jobs of Gitea Actions workflows. Register it with a Gitea instance to run jobs in Docker containers, Kubernetes pods or directly on the host.
## Installation ## Installation
### Prerequisites ### Prerequisites
Docker Engine Community version is required for docker mode. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/). - Linux, macOS or Windows, or FreeBSD.
- Gitea 1.26.2 or newer.
- `git` 2.34.1 or newer.
- `docker`, or `podman` with `podman.socket` enabled, for `docker` labels and Docker actions. See Docker's [install instructions](https://docs.docker.com/engine/install/).
In the job image, or on the host for host jobs:
- `bash` or `sh`, or `pwsh` or `powershell` on Windows.
- `node` for JavaScript actions and `hashFiles()`.
Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images.
### Download pre-built binary ### Download pre-built binary
Visit [here](https://dl.gitea.com/gitea-runner/) and download the right version for your platform. Download the binary for your platform from [dl.gitea.com](https://dl.gitea.com/gitea-runner/).
### Build from source ### Build from source
@@ -24,7 +36,7 @@ make docker
## Quickstart ## Quickstart
Actions are disabled by default, so you need to add the following to the configuration file of your Gitea instance to enable it: Enable Actions in the configuration file of your Gitea instance:
```ini ```ini
[actions] [actions]
@@ -37,239 +49,287 @@ ENABLED=true
./gitea-runner register ./gitea-runner register
``` ```
And you will be asked to input: It asks for:
1. Gitea instance URL, like `http://192.168.8.8:3000/`. You should use your gitea instance ROOT_URL as the instance argument 1. The Gitea instance URL. Use its `ROOT_URL`, not `localhost` or `127.0.0.1`.
and you should not use `localhost` or `127.0.0.1` as instance IP; 2. A runner token, from `/admin/actions/runners` on your instance.
2. Runner token, you can get it from `http://192.168.8.8:3000/admin/actions/runners`; 3. A runner name, the hostname when left blank.
3. Runner name, you can just leave it blank; 4. The runner [labels](#labels), the defaults when left blank.
4. Runner labels, you can just leave it blank.
The process looks like: To register without prompts:
```text
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.
```bash ```bash
./gitea-runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive ./gitea-runner register --instance https://gitea.example.com --token <token> --no-interactive
``` ```
If the registry succeed, it will run immediately. Next time, you could run the runner directly.
### Run ### Run
```bash ```bash
./gitea-runner daemon ./gitea-runner daemon
``` ```
## Deployment
### Windows service
Register the runner first, keeping `gitea-runner.exe`, `config.yaml` and the `.runner` file in one directory. Then install and start the service from an elevated Command Prompt:
```bat
sc.exe create gitea-runner binPath= "\"C:\gitea-runner\gitea-runner.exe\" daemon --config \"C:\gitea-runner\config.yaml\"" start= auto
sc.exe start gitea-runner
```
The service logs to `gitea-runner.log` next to the executable. Its account needs write access to that directory and access to the tools jobs use.
`sc.exe stop gitea-runner` lets running jobs finish within `runner.shutdown_timeout`. To allow the same on shutdown, raise the service's preshutdown timeout above it, here to six minutes. This takes effect after the next reboot:
```bat
reg.exe add HKLM\SYSTEM\CurrentControlSet\Services\gitea-runner /v PreshutdownTimeout /t REG_DWORD /d 360000 /f
```
### Run with docker ### Run with docker
```bash The recommended Docker-in-Docker image runs its own Docker daemon for jobs. Jobs never get the host's Docker socket, which is equivalent to root access on the host.
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](scripts/run.sh)).
> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well — otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist.
### Image flavours
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
| --- | --- | --- | --- | --- | --- |
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/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:
```bash
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`](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:
```bash ```bash
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \ 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 -v "$PWD/data:/data" -v runner-docker:/var/lib/docker --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`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](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`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged. The volumes keep the registration and the image cache across container recreation. The image reads more variables, such as `GITEA_RUNNER_LABELS` and `CONFIG_FILE`, see [scripts/run.sh](scripts/run.sh) and the [Docker example](examples/docker/README.md).
#### `latest-dind-rootless` — rootless Docker-in-Docker #### Image flavours
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). | Tag | Docker daemon for jobs |
| --- | --- |
| `latest-dind` (recommended) | Bundled, needs `--privileged`. |
| `latest-dind-rootless` | Bundled, runs as UID 1000 for stronger isolation. Needs `--privileged` and [rootless Docker](https://docs.docker.com/engine/security/rootless/) support on the host. Its image cache is in `/home/rootless/.local/share/docker`. |
| `latest` | None. Mount the host's `/var/run/docker.sock`, or set `DOCKER_HOST`. |
> **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. Versioned and `nightly` tags take the same suffixes, like `nightly-dind`. The images target Docker, not Podman.
> **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. ### Example deployments
### Configuration See the [examples](examples) directory for more deployments, among them systemd, launchd on macOS, Kubernetes and VMs.
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree): ## Configuration
The runner reads an optional YAML file:
```bash ```bash
./gitea-runner generate-config > config.yaml ./gitea-runner config init # write config.yaml, with no option set
./gitea-runner config generate | less # read what the options do
./gitea-runner -c config.yaml daemon # -c also works on register and cache-server
``` ```
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`): `config generate` prints [config.example.yaml](internal/pkg/config/config.example.yaml), which documents every option. Copy the lines you need into your file and uncomment them.
### Editing a config file
`config` edits a file in place and keeps its comments, which suits provisioning scripts:
```bash ```bash
./gitea-runner -c config.yaml register ./gitea-runner config set runner.capacity 4
./gitea-runner -c config.yaml daemon ./gitea-runner config set runner.envs.MY_VAR value
./gitea-runner -c config.yaml cache-server ./gitea-runner config add runner.labels 'ubuntu:docker://node:22'
./gitea-runner config remove runner.labels 'ubuntu:docker://node:22'
./gitea-runner config get runner.labels
``` ```
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `generate-config` prints). Keys are dotted YAML paths. Unknown keys and invalid values are refused without touching the file. Without `-c`, it uses `config.yaml` from the working directory, then from the binary's directory.
#### Without a config file ### Environment variables
If you omit `-c`, built-in defaults apply (same as an empty YAML document). Config values can read the runner's environment as `${NAME}`, for example `capacity: ${RUNNER_CAPACITY}`. An unset variable fails the load, and `$${NAME}` stays a literal `${NAME}`.
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. The override variables of earlier releases, such as `GITEA_RUNNER_CAPACITY`, are gone. Set those options in the file instead.
### Labels ### 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. Labels decide which jobs a runner accepts and how it runs them. A job's `runs-on` is matched against the label names, and the first match picks where it runs.
A label is written as: A label is written as `<name>[:<schema>[:<args>]]`, with one of these schemas:
```text - `docker://<image>` runs the job in a container of `<image>`.
<name>[:<schema>[:<args>]] - `kubernetes://<image>` runs it as a Kubernetes pod, see [docs/kubernetes.md](docs/kubernetes.md).
``` - `host`, the default, runs its steps directly on the runner's machine.
| Part | Meaning | With these labels, `runs-on: ubuntu-latest` runs in a container and `runs-on: macos` on the host:
| --- | --- |
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
| `args` | Only used by the `docker` schema: the image to run the job in. |
Two schemas are supported:
- **`docker://<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 ```text
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host 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. [gitea/runner-images](https://gitea.com/gitea/runner-images) has suitable images. Jobs matching no label run in `runner.default_image`, or on the host when docker is unavailable.
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas. Host jobs still need a Docker daemon for Docker actions and `docker://` steps. Windows hosts do not support those, as on GitHub. A step's background processes end with the job unless it opts out, see [host mode](docs/host-mode.md).
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. Labels are set at registration, with `--labels` or the prompt. To change them later, use one of these, the leftmost set one wins:
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 daemon --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. Changed labels are saved to the `.runner` file and sent to Gitea on startup. When `runner.labels` is set, `register` uses it and ignores `--labels`.
> **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. ### Tool cache
#### Caching (`actions/cache`) Setup actions like `setup-go` install tools into `RUNNER_TOOL_CACHE`. `runner.tool_cache_mode` selects what backs it:
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default. | Mode | Tool cache |
| --- | --- |
| `none` (default) | Provided by the job image. Versions it lacks are downloaded in every job. |
| `shared` | One volume per `runner.capacity` slot, reused by later jobs. It hides the tools later job images ship, so use images without them. |
**Shared cache across multiple runners** To ship tools in a job image, install them to `/opt/hostedtoolcache/<tool>/<version>/<arch>` with an empty `<arch>.complete` file next to it, as the `-full` [runner images](https://gitea.com/gitea/runner-images) do:
Run one dedicated `gitea-runner cache-server` that all runners point at. ```dockerfile
RUN GO=$(curl -fsSL 'https://go.dev/dl/?mode=json' | grep -oP '"version": "\Kgo1\.26\.[0-9]*' | head -1); \
DIR="/opt/hostedtoolcache/go/${GO#go}/x64" && \
mkdir -p "$(dirname "$DIR")" && \
curl -fsSL "https://dl.google.com/go/${GO}.linux-amd64.tar.gz" | tar -xz -C /tmp && \
mv /tmp/go "$DIR" && \
touch "${DIR}.complete"
```
1. Create a config file for the cache server host: ### Proxy
```yaml Set the proxy in the runner's environment, for example with systemd `Environment=` or `docker run -e`:
cache:
```sh
http_proxy=http://proxy.example:3128
https_proxy=http://proxy.example:3128
no_proxy=gitea.internal,.example.local
```
The runner uses it and passes it on to jobs. Jobs always reach the cache server, `localhost`, their service containers and a `tcp://` Docker daemon directly. Add Gitea to `no_proxy` if it should be reached directly too.
To change the proxy for jobs, use `runner.envs`, a step's `env:` or `$GITHUB_ENV`. Workflow and job level `env:` have no effect on it.
The Docker daemon pulls images with its own proxy settings. The `dind` images pass the variables above to it, for other daemons see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/).
A password in the proxy URL is hidden in logs, but every step can read it.
## Jobs
### Service containers
When a service declares a healthcheck, in its image or `options`, the job waits for it to become healthy:
```yaml
services:
postgres:
image: postgres:18
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-retries 10
```
A service that turns unhealthy, or is not healthy within `container.service_ready_timeout`, fails the job. Steps reach a service by its id and port, like `psql -h postgres -p 5432`.
Unlike GitHub, jobs running on the host start no services. Give them a `container:` to use services.
### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
Containers a job starts can bind-mount the workspace by its path, like `docker run -v "$PWD:/src"`. Where the Docker daemon sees another path, for example a remote `DOCKER_HOST`, prefix the bind with `GITEA_DOCKER_WORKSPACE`:
```yaml
volumes:
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
```
Set `container.sweep: true` to remove the containers, networks and volumes most Linux container jobs create when they end, `docker compose` services included. Leave it off if jobs deploy services to the runner's Docker daemon.
### Caching (`actions/cache`)
Each runner runs its own cache server. As on GitHub, a job restores caches of its own ref, its pull request's base branch and the default branch.
#### Sharing a cache between runners
Run one `gitea-runner cache-server` and point every runner at it with the same secret, for example from `openssl rand -hex 32`:
```yaml
# cache-server.yaml
cache:
dir: /data/actcache dir: /data/actcache
port: 8088 port: 8088
external_secret: "replace-with-a-strong-random-secret" external_secret: "<secret>" # or external_secret_file: /path/to/secret
# external_secret_file: /path/to/secret # secret can also be passed via a file ```
```
2. Start the server: ```bash
gitea-runner -c cache-server.yaml cache-server
```
```bash ```yaml
gitea-runner -c cache-server-config.yaml cache-server # each runner's config
``` cache:
3. On every runner:
```yaml
cache:
external_server: "http://<cache-server-host>:8088/" external_server: "http://<cache-server-host>:8088/"
external_secret: "replace-with-a-strong-random-secret" # must match the server external_secret: "<secret>"
# external_secret_file: /path/to/secret # secret can also be passed via a file ```
```
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories. Runners can also share one `cache.dir` on a file system with working file locks, at the cost of slower requests.
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point. #### Storing the cache in an S3-compatible bucket
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file. Runners can share a cache in an S3-compatible bucket, such as AWS S3, MinIO or RustFS, without a cache server:
#### Official Docker image ```yaml
cache:
s3:
endpoint: "http://minio:9000"
region: "us-east-1"
bucket: "runner-cache"
prefix: "gitea-runner-cache"
path_style: true
access_key_file: "/run/secrets/cache-s3-access-key"
secret_key_file: "/run/secrets/cache-s3-secret-key"
```
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](scripts/run.sh) for exact behavior. Eviction does not apply to the bucket. Add lifecycle rules that expire old caches and abort incomplete multipart uploads.
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md). #### Eviction
When `container.bind_workdir` is enabled, stale task workspace directories can be cleaned while the runner is idle: Entries unused for `cache.retention` (default `168h`) are removed. A repository over `cache.repo_size_limit` (default `10GB`) loses its least recently used entries. See [config.example.yaml](internal/pkg/config/config.example.yaml) for more limits.
- directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable)
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
#### Post-task script (`runner.post_task_script`) #### Cache service v2
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. The runner serves the cache service v2 API used by current `actions/cache` releases. It patches them to use it on Gitea, which also makes the stock `actions/upload-artifact` and `actions/download-artifact` v4 work. Artifact calls then go through the cache server, so jobs must be able to reach it.
**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. ### Job hooks (`runner.hooks.job_started`, `runner.hooks.job_completed`)
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. Hook scripts run where the job's steps run, before the first step and after the last. They match GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` and `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`.
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes. Use them for per-job setup no workflow should carry, like registry logins or masking runner-wide secrets. They block the job, and a non-zero exit fails it. See [docs/job-hooks.md](docs/job-hooks.md).
### Example Deployments ### Secret masking
Check out the [examples](examples) directory for sample deployment types. Secrets and `::add-mask::` values are hidden in job logs, [local job logs](#local-job-logs-logjobdir), job summaries and container names. As on GitHub, a job output containing one is dropped with a warning.
## Operation
### Idle cleanup
While idle, the runner removes leftovers of earlier jobs every `runner.idle_cleanup_interval` (default `10m`):
- Workspaces older than `runner.workdir_cleanup_age` (default `24h`), when `container.bind_workdir` is on. This assumes no other runner shares `container.workdir_parent`.
- Unused Docker networks and volumes of jobs the runner did not tear down.
- Kubernetes job pods older than `runner.workdir_cleanup_age`, with their Secrets.
Set either option to `0` to turn cleanup off.
### Post-task script (`runner.post_task_script`)
A host script that runs after each task's cleanup, for housekeeping like Docker pruning. The runner takes no new jobs until it exits or hits `runner.post_task_script_timeout` (default `5m`). Its output goes to the runner log.
On Windows, use an `.exe`, `.bat` or `.cmd` file, PowerShell scripts are not supported. See [docs/post-task-script.md](docs/post-task-script.md).
### Local job logs (`log.job.dir`)
Set `log.job.dir` to keep a copy of every task's log, masked as in Gitea, with the job's result on the last line. Copies are kept for `log.job.retention` (default `168h`), so keep it above `runner.timeout`. Only the runner's user can read them.
### OpenTelemetry (`OTEL_*`)
Set `OTEL_EXPORTER_OTLP_ENDPOINT` in the runner's environment to send job traces and runner metrics to an OpenTelemetry collector. See [docs/telemetry.md](docs/telemetry.md).
-2
View File
@@ -6,7 +6,5 @@
// //
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server // Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
// //
// TODO: Authorization
// TODO: Restrictions for accessing a cache, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache
// TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries // TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
package artifactcache package artifactcache
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+335
View File
@@ -0,0 +1,335 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"cmp"
"encoding/json/jsontext"
"encoding/json/v2"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// The cache service v2 API. A client on this version talks twirp to
// `github.actions.results.api.v1.CacheService` instead of the /_apis/artifactcache
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
// Both API versions are served from the same store, so a repository keeps its cache
// when a workflow moves between action versions.
//
// Responses carry the proto field names, which is what Gitea's own results API emits and the only
// spelling the Go clients parse. The JavaScript toolkit accepts either.
const (
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
// blobPath authenticates by signature, because the client uploads without an
// Authorization header. Downloads are handed the v1 artifact URL instead.
blobPath = apiPath + "/blobs"
// blobUploadPurpose keeps an upload URL from being replayed to read an entry.
blobUploadPurpose = "upload:"
blobUploadURLTTL = time.Hour
twirpInternal = "internal"
twirpUnavailable = "unavailable"
twirpUnauthenticated = "unauthenticated"
)
func (h *Handler) registerV2Routes(router *http.ServeMux) {
router.HandleFunc("POST "+cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
router.HandleFunc("POST "+cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
router.HandleFunc("POST "+cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
router.HandleFunc("PUT "+blobPath+"/{id}", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
}
// An entry that already exists is reported as not ok, which is how the client learns to skip
// the upload.
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) {
cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2CreateRequest](r)
if err != nil {
h.twirpError(w, r, "malformed", err)
return
}
if req.Key == "" || req.Version == "" {
h.twirpError(w, r, "invalid_argument", errors.New("key and version are required"))
return
}
db, err := h.openDB()
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
defer db.Close()
// An exact (key, version) match means the entry is already cached; the client then skips
// the upload. A prefix match must not count here, or a shorter key would be reported as
// existing and silently never saved.
if existing, err := findExactCache(db, cred, req.Key, req.Version, true); err != nil {
h.twirpError(w, r, twirpInternal, err)
return
} else if existing != nil {
h.touch(db, existing) // the client skips the upload, so this is the only sign the entry is still in use
h.twirpNotOK(w, r)
return
}
// A second live reservation is finalized by whichever job calls last, against the other's upload.
owner := hashedToken(bearerToken(r))
if pending, err := findExactCache(db, cred, req.Key, req.Version, false); err != nil {
h.twirpError(w, r, twirpInternal, err)
return
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
if pending.Owner != owner {
h.twirpNotOK(w, r)
return
}
h.touch(db, pending) // still uploading, so it must not go stale under the sweep
h.responseJSON(w, r, http.StatusOK, map[string]any{ // this job retrying its own reservation
"ok": true,
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, pending.ID, time.Now().Add(blobUploadURLTTL)),
})
return
}
now := time.Now()
cache := &Cache{
Repo: cred.Repo,
Ref: cred.Ref,
Key: req.Key,
Version: req.Version,
Owner: owner,
Size: -1, // the size is only known at finalize time
CreatedAt: now.Unix(),
CreatedAtNanos: now.Nanosecond(),
UsedAt: now.Unix(),
}
if err := h.insertCache(db, cache); err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
h.responseJSON(w, r, http.StatusOK, map[string]any{
"ok": true,
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
})
}
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request) {
cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
if err != nil {
h.twirpError(w, r, "malformed", err)
return
}
db, err := h.openDB()
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
defer db.Close()
cache, err := findExactCache(db, cred, req.Key, req.Version, false)
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
cache = nil // not the reservation this job made, so not this job's to commit
}
if err == nil && cache == nil {
// A retry whose first response was lost finds the entry already committed.
cache, err = findExactCache(db, cred, req.Key, req.Version, true)
}
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
if cache == nil {
h.twirpNotOK(w, r)
return
}
if !cache.Complete {
db.Close() // commitCache needs the store closed
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
if err := h.commitCache(cache); err != nil {
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
h.twirpNotOK(w, r)
return
}
}
h.responseJSON(w, r, http.StatusOK, map[string]any{
"ok": true,
// int64 fields travel as strings in the proto JSON mapping.
"entry_id": strconv.FormatUint(cache.ID, 10),
})
}
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request) {
cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2DownloadRequest](r)
if err != nil {
h.twirpError(w, r, "malformed", err)
return
}
db, err := h.openDB()
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
defer db.Close()
cache, err := h.lookupCache(db, cred, req.keys(), req.Version)
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
if cache == nil {
h.twirpNotOK(w, r)
return
}
h.responseJSON(w, r, http.StatusOK, map[string]any{
"ok": true,
"signed_download_url": h.signedArtifactURL(cred, cache.ID, time.Now().Add(artifactURLTTL)),
"matched_key": cache.Key,
})
}
// The archive arrives over the subset of the Azure blob API the toolkit uses: a small
// cache is a single PUT, a large one is staged as blocks that a final block list puts
// in order.
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
if err := h.touchCache(id, true); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
h.makeRoom(r.ContentLength)
query := r.URL.Query()
switch strings.ToLower(query.Get("comp")) {
case "block":
blockID := query.Get("blockid")
if blockID == "" {
h.responseJSON(w, r, http.StatusBadRequest, errors.New("missing blockid"))
return
}
err = h.storage.WriteBlock(id, blockID, r.Body)
case "blocklist":
var list struct{ Latest []string }
if err := xml.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&list); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, fmt.Errorf("malformed block list: %w", err))
return
}
err = h.storage.OrderBlocks(id, list.Latest)
default:
err = h.storage.Write(id, 0, r.Body)
}
if err != nil {
h.responseJSON(w, r, http.StatusInternalServerError, err)
return
}
// The Azure SDK client dereferences this without checking, so its absence panics the caller.
w.Header().Set("x-ms-request-id", strconv.FormatInt(time.Now().UnixNano(), 10))
w.WriteHeader(http.StatusCreated)
}
// twirpNotOK is the negative answer all three endpoints share: no such entry to restore, no
// reservation to finalize, or an entry that already exists and need not be uploaded again.
func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false})
}
// twirpError reports in the shape a twirp client expects, so the toolkit surfaces the message
// instead of a parse error.
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
status := http.StatusBadRequest
switch code {
case twirpInternal:
status = http.StatusInternalServerError
case twirpUnavailable:
status = http.StatusServiceUnavailable
case twirpUnauthenticated:
status = http.StatusUnauthorized
}
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
}
// The twirp request bodies. The toolkit's client serialises with useProtoFieldName, so the proto
// names are what arrive; the camelCase spellings of the same mapping are accepted too, as are
// int64s sent as a bare number rather than the string the mapping prescribes.
type (
v2CreateRequest struct {
Key string `json:"key"`
Version string `json:"version"`
}
v2FinalizeRequest struct {
Key string `json:"key"`
Version string `json:"version"`
SizeBytes twirpInt64 `json:"size_bytes"`
SizeBytesCamel twirpInt64 `json:"sizeBytes"`
}
v2DownloadRequest struct {
Key string `json:"key"`
Version string `json:"version"`
RestoreKeys []string `json:"restore_keys"`
RestoreKeysCamel []string `json:"restoreKeys"`
}
)
// twirpInt64 accepts its value as the JSON string the mapping prescribes or as a bare number.
type twirpInt64 int64
func (n *twirpInt64) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
val, err := dec.ReadValue()
if err != nil {
return err
}
digits := []byte(val)
switch val.Kind() {
case 'n': // absent, keep the zero value
return nil
case '"':
if digits, err = jsontext.AppendUnquote(nil, val); err != nil {
return err
}
}
parsed, err := strconv.ParseInt(string(digits), 10, 64)
if err != nil {
return err
}
*n = twirpInt64(parsed)
return nil
}
func (d v2DownloadRequest) keys() []string {
restoreKeys := d.RestoreKeys
if len(restoreKeys) == 0 {
restoreKeys = d.RestoreKeysCamel
}
return append([]string{d.Key}, restoreKeys...)
}
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
var req T
err := json.UnmarshalRead(io.LimitReader(r.Body, 1<<20), &req)
return req, err
}
+347
View File
@@ -0,0 +1,347 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"bytes"
"encoding/base64"
"encoding/json/v2"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any {
t.Helper()
body, err := json.Marshal(request)
require.NoError(t, err)
resp, err := client.Post(handler.ExternalURL()+cacheServiceV2Path+"/"+method, "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
got := map[string]any{}
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
return got
}
func putBlob(t *testing.T, url string, content []byte) int {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, url, bytes.NewReader(content))
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated {
require.NotEmpty(t, resp.Header.Get("x-ms-request-id"))
}
return resp.StatusCode
}
func getURL(t *testing.T, url string) []byte {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return body
}
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
t.Helper()
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
require.Equal(t, true, created["ok"])
uploadURL, _ = created["signed_upload_url"].(string)
require.NotEmpty(t, uploadURL)
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
return v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": key, "version": version,
"size_bytes": strconv.Itoa(len(content)),
}), uploadURL
}
func TestCacheServiceV2RoundTrip(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{})
content := []byte("the cached archive")
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
unreserved := handler.signedURL(JobCredential{Repo: testRepo}, blobPath, blobUploadPurpose, 1, time.Now().Add(time.Minute))
assert.Equal(t, http.StatusBadRequest, putBlob(t, unreserved, content))
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
require.Equal(t, true, finalized["ok"])
assert.NotEmpty(t, finalized["entry_id"])
assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned")))
resp, err := http.Get(uploadURL)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
require.Equal(t, true, got["ok"])
assert.Equal(t, "deps-v1", got["matched_key"])
downloadURL, _ := got["signed_download_url"].(string)
require.NotEmpty(t, downloadURL)
assert.Equal(t, content, getURL(t, downloadURL))
}
func TestCacheServiceV2BlockUpload(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{})
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
uploadURL, _ := created["signed_upload_url"].(string)
require.NotEmpty(t, uploadURL)
blocks := map[string][]byte{}
var order []string
for i, part := range []string{"hello ", "world", "!"} {
blockID := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "block-%d", i))
blocks[blockID] = []byte(part)
order = append(order, blockID)
}
for _, blockID := range []string{order[2], order[0], order[1]} {
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID]))
}
var list bytes.Buffer
list.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
for _, blockID := range order {
fmt.Fprintf(&list, "<Latest>%s</Latest>", blockID)
}
list.WriteString(`</BlockList>`)
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()),
"a client that lost the first answer retries the list it already sent")
var reordered bytes.Buffer
reordered.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
for _, blockID := range []string{order[1], order[0], order[2]} {
fmt.Fprintf(&reordered, "<Latest>%s</Latest>", blockID)
}
reordered.WriteString(`</BlockList>`)
assert.Equal(t, http.StatusInternalServerError, putBlob(t, uploadURL+"&comp=blocklist", reordered.Bytes()),
"the blocks are already assembled, so a different order would silently disagree with them")
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
})
require.Equal(t, true, finalized["ok"])
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
require.Equal(t, true, got["ok"])
assert.Equal(t, "hello world!", string(getURL(t, got["signed_download_url"].(string))))
}
func TestCacheServiceV2Lookups(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{})
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
require.Equal(t, true, saved["ok"])
t.Run("reports a miss for an unknown key", func(t *testing.T) {
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "nothing", "version": "v1"})
assert.Equal(t, false, got["ok"])
})
for _, field := range []string{"restore_keys", "restoreKeys"} {
t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) {
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
})
require.Equal(t, true, got["ok"])
assert.Equal(t, "deps-abc", got["matched_key"])
})
}
t.Run("an existing entry is not reserved twice", func(t *testing.T) {
again := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
assert.Equal(t, false, again["ok"])
})
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
require.Equal(t, true, reserved["ok"])
assert.NotEmpty(t, reserved["signed_upload_url"])
})
t.Run("a proxied job is handed the address its runner registered", func(t *testing.T) {
const proxy = "https://cache.example.invalid"
handler.RegisterJob("proxied", JobCredential{Repo: testRepo, PublicURL: proxy + "/"})
client := &http.Client{Transport: &bearerTransport{token: "proxied"}}
created := v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "proxied-key", "version": "v1"})
assert.True(t, strings.HasPrefix(created["signed_upload_url"].(string), proxy+blobPath+"/"))
got := v2Call(t, handler, client, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
assert.True(t, strings.HasPrefix(got["signed_download_url"].(string), proxy+apiPath+"/artifacts/"))
})
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "never-reserved", "version": "v1", "size_bytes": 1,
})
assert.Equal(t, false, got["ok"])
})
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte("four")))
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "wrong-size", "version": "v1", "size_bytes": 99,
})
assert.Equal(t, false, got["ok"])
})
t.Run("a v1 client sees an entry written through v2", func(t *testing.T) {
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
got := map[string]any{}
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, "deps-abc", got["cacheKey"])
assert.NotEmpty(t, got["archiveLocation"])
})
t.Run("another repository sees nothing", func(t *testing.T) {
handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"})
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
assert.Equal(t, false, got["ok"])
})
t.Run("each ref saves and restores its own entry for the same key", func(t *testing.T) {
clients := map[string]*http.Client{"x": testClient}
for _, ref := range []string{"y", "z"} {
handler.RegisterJob(ref, JobCredential{Repo: testRepo, Ref: "refs/pull/" + ref + "/head"})
clients[ref] = &http.Client{Transport: &bearerTransport{token: ref}}
created := v2Call(t, handler, clients[ref], "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
require.Equal(t, true, created["ok"])
require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte(ref)))
}
for _, ref := range []string{"y", "z"} {
require.Equal(t, true, v2Call(t, handler, clients[ref], "FinalizeCacheEntryUpload", map[string]any{"key": "deps-abc", "version": "v1", "size_bytes": 1})["ok"])
}
for content, client := range clients {
got := v2Call(t, handler, client, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
assert.Equal(t, content, string(getURL(t, got["signed_download_url"].(string))))
}
})
}
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{})
tests := []struct {
name string
token string
body string
status int
code string
}{
{"no bearer at all", "", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
{"a bearer nobody registered", "not-a-job", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
{"a body that is not the request", testToken, `{`, http.StatusBadRequest, "malformed"},
{"a request missing its key", testToken, `{"version":"v"}`, http.StatusBadRequest, "invalid_argument"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", strings.NewReader(tt.body))
require.NoError(t, err)
if tt.token != "" {
req.Header.Set("Authorization", "Bearer "+tt.token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, tt.status, resp.StatusCode)
got := map[string]string{}
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, tt.code, got["code"])
assert.NotEmpty(t, got["msg"])
})
}
}
func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{})
content := []byte("the cached archive")
const otherJob = "another-jobs-token"
defer handler.RegisterJob(otherJob, JobCredential{Repo: testRepo})()
reserve := func(client *http.Client) map[string]any {
return v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "shared", "version": "v1"})
}
first := reserve(testClient)
require.Equal(t, true, first["ok"])
retry := reserve(testClient)
assert.Equal(t, true, retry["ok"], "a job retrying its own reservation is not a conflict")
entryOf := func(reserved map[string]any) string {
parsed, err := url.Parse(reserved["signed_upload_url"].(string))
require.NoError(t, err)
return parsed.Path
}
assert.Equal(t, entryOf(first), entryOf(retry), "the same reservation, however freshly its URL is signed")
assert.Equal(t, false, reserve(&http.Client{Transport: &bearerTransport{token: otherJob}})["ok"],
"another job's reservation would be finalized against the first upload")
otherClient := &http.Client{Transport: &bearerTransport{token: otherJob}}
reserved := v2Call(t, handler, otherClient, "CreateCacheEntry", map[string]any{"key": "owned", "version": "v1"})
require.Equal(t, true, reserved["ok"])
require.Equal(t, http.StatusCreated, putBlob(t, reserved["signed_upload_url"].(string), content))
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
map[string]any{"key": "owned", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
"another job's upload is not this job's to finalize")
base := handler.ExternalURL() + apiPath
madeByV1 := reserveCache(t, testClient, base, "v1-made", "v1", int64(len(content)))
upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, madeByV1), bytes.NewReader(content))
require.NoError(t, err)
upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
resp, err := testClient.Do(upload)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
map[string]any{"key": "v1-made", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
"a reservation this job did not make through v2 is not its to commit")
finalized, _ := saveV2(t, handler, "deps", "v1", content)
require.Equal(t, true, finalized["ok"])
again := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "deps", "version": "v1", "size_bytes": strconv.Itoa(len(content)),
})
assert.Equal(t, true, again["ok"], "a retry whose first answer was lost must not report a failed save")
assert.Equal(t, finalized["entry_id"], again["entry_id"])
}
+16 -24
View File
@@ -4,36 +4,28 @@
package artifactcache package artifactcache
import "time"
type Request struct { type Request struct {
Key string `json:"key" ` Key string `json:"key" `
Version string `json:"version"` Version string `json:"version"`
Size int64 `json:"cacheSize"` Size int64 `json:"cacheSize"`
} }
func (c *Request) ToCache() *Cache { type Cache struct {
if c == nil { ID uint64 `json:"id"`
return nil Repo string `json:"repo"`
} Ref string `json:"ref,omitzero"`
ret := &Cache{ Key string `json:"key"`
Key: c.Key, Version string `json:"version"`
Version: c.Version, Size int64 `json:"cacheSize"`
Size: c.Size, Owner string `json:"owner"` // hashed bearer, so a job's own retry is not another job's reservation
} Complete bool `json:"complete"`
if c.Size == 0 { UsedAt int64 `json:"usedAt"`
// So the request comes from old versions of actions, like `actions/cache@v2`. CreatedAt int64 `json:"createdAt"`
// It doesn't send cache size. Set it to -1 to indicate that. CreatedAtNanos int `json:"createdAtNanos,omitzero"`
ret.Size = -1
}
return ret
} }
type Cache struct { func (cache *Cache) createdAtNano() int64 {
ID uint64 `json:"id" boltholdKey:"ID"` return cache.CreatedAt*int64(time.Second) + int64(cache.CreatedAtNanos)
Repo string `json:"repo" boltholdIndex:"Repo"`
Key string `json:"key" boltholdIndex:"Key"`
Version string `json:"version" boltholdIndex:"Version"`
Size int64 `json:"cacheSize"`
Complete bool `json:"complete" boltholdIndex:"Complete"`
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
} }
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"cmp"
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// The results service is one origin serving every github.actions.results.api.v1 service, and
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
// this runner cannot patch need, docker buildx among them.
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
// registered with, and the 404 the router would have written otherwise.
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
http.NotFound(w, r)
return
}
cred, ok := h.lookupCredential(bearerToken(r))
if !ok {
h.twirpError(w, r, twirpUnauthenticated, errors.New("unknown bearer token"))
return
}
if cred.Results == "" {
h.twirpError(w, r, twirpInternal, errors.New("no instance is registered for this job"))
return
}
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
if err == nil && (target.Hostname() == "" || (target.Scheme != "http" && target.Scheme != "https")) {
err = errors.New("not an absolute http or https URL the job could use")
}
if err != nil {
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
h.twirpError(w, r, twirpInternal, fmt.Errorf("artifact service address %q is unusable: %w", cred.Results, err))
return
}
if !h.mustProxy(r, cred, target) {
redirect := target.JoinPath(r.URL.EscapedPath())
redirect.RawQuery = mergeQuery(target.RawQuery, r.URL.RawQuery)
h.logger.Debugf("%s %s: redirecting to %s", r.Method, r.URL.Path, target)
http.Redirect(w, r, redirect.String(), http.StatusTemporaryRedirect)
return
}
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target)
// Gitea builds the URLs it hands back from this Host, and their scheme from the
// connection unless a forwarded header overrides it, so artifact bodies go to Gitea
// directly and never through here.
r.Out.Host = target.Host
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
h.logger.Warnf("artifact service forward to %s: %v", target, err)
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
},
}
if proxy.Transport = h.instanceTransport; proxy.Transport == nil && cred.InsecureTLS { // the instance transport carries runner.insecure and the client cert
proxy.Transport = insecureTransport
}
proxy.ServeHTTP(w, r)
}
// A protocol switch loses the bearer or the agent, and an untrusted instance needs skipped verification.
func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL) bool {
return cred.InsecureTLS && target.Scheme == "https" || cmp.Or(r.Header.Get("X-Forwarded-Proto"), "http") != target.Scheme
}
func mergeQuery(target, request string) string {
if target == "" || request == "" {
return target + request
}
return target + "&" + request
}
// insecureTransport is shared, because a transport per request would pool no connections.
var insecureTransport = &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
+152
View File
@@ -0,0 +1,152 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"cmp"
"encoding/json/v2"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFrontResultsService(t *testing.T) {
t.Parallel()
var gotHost, gotPath, gotProto string
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHost, gotPath, gotProto = r.Host, r.URL.Path, r.Header.Get("X-Forwarded-Proto")
_, _ = io.WriteString(w, `{"ok":true}`)
}))
defer gitea.Close()
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
defer selfSigned.Close()
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
require.NoError(t, err)
defer handler.Close()
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
noRedirect := func(token string) *http.Client {
return &http.Client{
Transport: &bearerTransport{token: token},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
}
post := func(path string) (int, map[string]string) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
require.NoError(t, err)
resp, err := noRedirect(token).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body := map[string]string{}
_ = json.UnmarshalRead(resp.Body, &body)
return resp.StatusCode, body
}
status, body := post(rpc)
assert.Equal(t, http.StatusUnauthorized, status, "an unregistered token is told so, not left to parse a 404 page")
assert.Equal(t, twirpUnauthenticated, body["code"])
assert.Contains(t, body["msg"], "bearer")
assert.NotEmpty(t, body["msg"])
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: selfSigned.URL, InsecureTLS: true})()
status, _ = post(rpc)
require.Equal(t, http.StatusOK, status, "an http job reaching an https instance must proxy")
assert.Equal(t, strings.TrimPrefix(selfSigned.URL, "https://"), gotHost, "Gitea must see the host it mints its URLs from")
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
assert.Equal(t, rpc, gotPath)
gotPath = ""
status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
assert.Equal(t, http.StatusNotFound, status)
status, _ = post("/api/v1/repos/owner/repo")
assert.Equal(t, http.StatusNotFound, status)
assert.Empty(t, gotPath, "only the artifact service is forwarded")
tests := []struct {
name string
cred JobCredential
forwardedProto string
reqQuery string
want int
location string
wantMsg string
}{
{name: "an instance the job reaches itself", cred: JobCredential{Results: gitea.URL}, want: http.StatusTemporaryRedirect},
{name: "a sub-path instance keeps its prefix", cred: JobCredential{Results: gitea.URL + "/sub"}, want: http.StatusTemporaryRedirect},
{name: "an instance with no certificate to distrust", cred: JobCredential{Results: gitea.URL, InsecureTLS: true}, want: http.StatusTemporaryRedirect},
{
name: "both queries survive", cred: JobCredential{Results: gitea.URL + "?tenant=one"}, reqQuery: "?run=7",
want: http.StatusTemporaryRedirect, location: gitea.URL + rpc + "?tenant=one&run=7",
},
{
name: "a trusted https instance is reached by the job itself",
cred: JobCredential{Results: selfSigned.URL}, forwardedProto: "https", want: http.StatusTemporaryRedirect,
},
{
name: "the job does not share this server's disregard for the certificate",
cred: JobCredential{Results: selfSigned.URL, InsecureTLS: true}, forwardedProto: "https", want: http.StatusOK,
},
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
{
name: "a forwarded https scheme onto an http instance is proxied",
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
},
{
name: "a scheme the job could not use is refused before it is sent one",
cred: JobCredential{Results: "ftp://gitea.example"}, want: http.StatusInternalServerError, wantMsg: "unusable",
},
{
name: "an address with no host is refused before it is sent one",
cred: JobCredential{Results: "http://:3000"}, want: http.StatusInternalServerError, wantMsg: "unusable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer handler.RegisterJob(tt.name, tt.cred)()
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+rpc+tt.reqQuery, nil)
require.NoError(t, err)
if tt.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
resp, err := noRedirect(tt.name).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, tt.want, resp.StatusCode)
switch tt.want {
case http.StatusTemporaryRedirect:
assert.Equal(t, cmp.Or(tt.location, tt.cred.Results+rpc), resp.Header.Get("Location"))
case http.StatusInternalServerError:
body := map[string]string{}
require.NoError(t, json.UnmarshalRead(resp.Body, &body))
assert.Equal(t, twirpInternal, body["code"])
assert.Contains(t, body["msg"], cmp.Or(tt.wantMsg, tt.cred.Results))
}
})
}
}
func TestFrontResultsServiceForwardsThroughTheInstanceTransport(t *testing.T) {
t.Parallel()
gitea := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _request *http.Request) { _, _ = io.WriteString(writer, "{}") }))
defer gitea.Close()
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InstanceTransport: gitea.Client().Transport})
require.NoError(t, err)
defer handler.Close()
defer handler.RegisterJob("token", JobCredential{Results: gitea.URL})()
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+artifactServicePath+"CreateArtifact", nil)
require.NoError(t, err)
resp, err := (&http.Client{Transport: &bearerTransport{token: "token"}}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
}
+348
View File
@@ -0,0 +1,348 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"bytes"
"cmp"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/json/v2"
"encoding/xml"
"errors"
"fmt"
"io"
"io/fs"
"maps"
"net/http"
"net/url"
"os"
"path"
"slices"
"strconv"
"strings"
"time"
"gitea.com/gitea/runner/act/common"
"golang.org/x/sync/errgroup"
)
// S3Options configures the bucket that stores completed cache entries.
type S3Options struct {
Endpoint string
Region string
Bucket string
Prefix string
PathStyle *bool // nil uses virtual-hosted requests on AWS and path-style ones elsewhere
AccessKey string
SecretKey string
}
type cacheStorage interface {
Exist(uint64) (bool, error)
Write(uint64, int64, io.Reader) error
WriteBlock(uint64, string, io.Reader) error
OrderBlocks(uint64, []string) error
Commit(uint64, int64) (int64, error)
Serve(http.ResponseWriter, *http.Request, uint64)
Remove(uint64) error
}
const s3PartSize = 64 << 20
// s3Storage stages uploads on disk and keeps completed entries and their metadata in a bucket.
type s3Storage struct {
*Storage
client *http.Client
bucketURL *url.URL
opts S3Options
}
type s3Part struct {
PartNumber int
ETag string
}
func newS3Storage(staging *Storage, opts S3Options) (*s3Storage, error) {
endpoint, err := url.Parse(opts.Endpoint)
if err != nil || endpoint.Host == "" || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
return nil, fmt.Errorf("invalid S3 endpoint %q", opts.Endpoint)
}
transport, _ := http.DefaultTransport.(*http.Transport)
transport = transport.Clone()
transport.MaxIdleConnsPerHost = 32 // 4 parts each for the 8 jobs a runner commonly runs
transport.ResponseHeaderTimeout = time.Minute // a stalled endpoint must not hold the store forever
pathStyle := !strings.HasSuffix(endpoint.Hostname(), ".amazonaws.com")
if opts.PathStyle != nil {
pathStyle = *opts.PathStyle
}
if pathStyle {
endpoint.Path = "/" + opts.Bucket
} else {
endpoint.Host = opts.Bucket + "." + endpoint.Host
endpoint.Path = "/"
}
opts.Region = cmp.Or(opts.Region, "us-east-1")
return &s3Storage{Storage: staging, client: &http.Client{Transport: transport}, bucketURL: endpoint, opts: opts}, nil
}
func (s *s3Storage) blobKey(id uint64) string {
return path.Join(s.opts.Prefix, "blobs", strconv.FormatUint(id, 10))
}
func (s *s3Storage) metadataDir() string {
return path.Join(s.opts.Prefix, "metadata") + "/"
}
func (s *s3Storage) metadataKey(id uint64) string {
return s.metadataDir() + strconv.FormatUint(id, 10) + ".json"
}
func (s *s3Storage) url(key string, query url.Values) *url.URL {
u := *s.bucketURL
u.Path = path.Join(u.Path, key)
u.RawPath = strings.NewReplacer("+", "%20", "%2F", "/").Replace(url.QueryEscape(u.Path)) // SigV4 encoding
u.RawQuery = strings.ReplaceAll(query.Encode(), "+", "%20")
return &u
}
// sign authorizes req with AWS Signature Version 4, leaving the payload unsigned so bodies can stream.
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
func (s *s3Storage) sign(req *http.Request, now time.Time) {
date := now.UTC().Format("20060102T150405Z")
req.Header.Set("X-Amz-Date", date)
if req.Header.Get("X-Amz-Content-Sha256") == "" {
req.Header.Set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD")
}
headers := map[string]string{"host": req.URL.Host}
for name, values := range req.Header {
if name = strings.ToLower(name); strings.HasPrefix(name, "x-amz-") {
headers[name] = strings.Join(values, ",")
}
}
names := slices.Sorted(maps.Keys(headers))
canonical := []string{req.Method, req.URL.EscapedPath(), req.URL.RawQuery}
for _, name := range names {
canonical = append(canonical, name+":"+headers[name])
}
canonical = append(canonical, "", strings.Join(names, ";"), req.Header.Get("X-Amz-Content-Sha256"))
scope := date[:8] + "/" + s.opts.Region + "/s3/aws4_request"
signature := []byte("AWS4" + s.opts.SecretKey)
for _, data := range []string{date[:8], s.opts.Region, "s3", "aws4_request", fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%x", date, scope, sha256.Sum256([]byte(strings.Join(canonical, "\n"))))} { // key derivation, the last round signs
mac := hmac.New(sha256.New, signature)
_, _ = mac.Write([]byte(data))
signature = mac.Sum(nil)
}
req.Header.Set("Authorization", fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s,SignedHeaders=%s,Signature=%x", s.opts.AccessKey, scope, strings.Join(names, ";"), signature))
}
// do returns the response to a signed request when it succeeded, fs.ErrNotExist for a missing key.
func (s *s3Storage) do(ctx context.Context, method, key string, query url.Values, header http.Header, body io.Reader) (*http.Response, error) {
var size int64
if sized, ok := body.(interface{ Size() int64 }); ok {
size = sized.Size()
}
if size == 0 {
body = nil
}
req, err := http.NewRequestWithContext(ctx, method, s.url(key, query).String(), body)
if err != nil {
return nil, err
}
req.ContentLength = size
maps.Copy(req.Header, header)
s.sign(req, time.Now())
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusMultipleChoices {
return resp, nil
}
defer drain(resp)
var failure struct{ Code, Message string }
_ = xml.NewDecoder(resp.Body).Decode(&failure)
if resp.StatusCode == http.StatusNotFound && (failure.Code == "NoSuchKey" || failure.Code == "" && method == http.MethodHead) {
return nil, fs.ErrNotExist
}
return nil, fmt.Errorf("S3 %s %s: %s", method, key, cmp.Or(strings.TrimSpace(failure.Code+" "+failure.Message), resp.Status))
}
func (s *s3Storage) call(ctx context.Context, method, key string, query url.Values, body io.Reader, result any) (http.Header, error) {
resp, err := s.do(ctx, method, key, query, nil, body)
if err != nil {
return nil, err
}
defer drain(resp)
if result == nil {
return resp.Header, nil
}
return resp.Header, xml.NewDecoder(resp.Body).Decode(result)
}
// drain reads a response to its end before closing it, so its connection gets reused.
func drain(resp *http.Response) {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
func (s *s3Storage) Exist(id uint64) (bool, error) {
_, err := s.call(context.Background(), http.MethodHead, s.blobKey(id), nil, nil, nil)
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return err == nil, err
}
func (s *s3Storage) Commit(id uint64, size int64) (int64, error) {
written, err := s.Storage.Commit(id, size)
if err != nil {
return 0, err
}
defer func() { _ = s.Storage.Remove(id) }()
file, err := os.Open(s.filename(id))
if err != nil {
return 0, err
}
defer file.Close()
if err := s.upload(context.Background(), s.blobKey(id), file, written); err != nil {
return 0, fmt.Errorf("upload cache to S3: %w", err)
}
return written, nil
}
func (s *s3Storage) upload(ctx context.Context, key string, file io.ReaderAt, size int64) (uploadErr error) {
if size <= s3PartSize {
_, err := s.call(ctx, http.MethodPut, key, nil, io.NewSectionReader(file, 0, size), nil)
return err
}
var created struct {
UploadID string `xml:"UploadId"`
}
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploads": {""}}, nil, &created); err != nil {
return err
}
defer func() {
if uploadErr != nil && created.UploadID != "" {
abortCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
_, _ = s.call(abortCtx, http.MethodDelete, key, url.Values{"uploadId": {created.UploadID}}, nil, nil)
}
}()
var completion struct {
XMLName xml.Name `xml:"CompleteMultipartUpload"`
Parts []s3Part `xml:"Part"`
}
partSize := max(int64(s3PartSize), (size-1)/10000+1)
completion.Parts = make([]s3Part, (size-1)/partSize+1)
workers, workerCtx := errgroup.WithContext(ctx)
workers.SetLimit(4)
for part := range completion.Parts {
workers.Go(func() (err error) {
defer common.RecoverPanic(&err)
offset := int64(part) * partSize
header, err := s.call(workerCtx, http.MethodPut, key, url.Values{"partNumber": {strconv.Itoa(part + 1)}, "uploadId": {created.UploadID}}, io.NewSectionReader(file, offset, min(partSize, size-offset)), nil)
completion.Parts[part] = s3Part{part + 1, header.Get("ETag")}
return err
})
}
if err := workers.Wait(); err != nil {
return err
}
body, err := xml.Marshal(completion)
if err != nil {
return err
}
var result struct{ Code, Message string } // a completion can fail after its 200 status
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploadId": {created.UploadID}}, bytes.NewReader(body), &result); err != nil {
return err
}
if result.Code != "" {
return fmt.Errorf("S3 complete upload %s: %s %s", key, result.Code, result.Message)
}
return nil
}
func (s *s3Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
header := http.Header{}
if byteRange := r.Header.Get("Range"); byteRange != "" {
header.Set("Range", byteRange)
}
resp, err := s.do(r.Context(), http.MethodGet, s.blobKey(id), nil, header, nil)
if errors.Is(err, fs.ErrNotExist) {
http.NotFound(w, r)
return
} else if err != nil {
http.Error(w, "read cache from S3", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for _, name := range []string{"Content-Length", "Content-Range"} {
if value := resp.Header.Get(name); value != "" {
w.Header().Set(name, value)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// Remove unpublishes an entry before deleting its blob, so no runner imports it without its bytes.
func (s *s3Storage) Remove(id uint64) error {
for _, key := range []string{s.metadataKey(id), s.blobKey(id)} {
if _, err := s.call(context.Background(), http.MethodDelete, key, nil, nil, nil); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove %s from S3: %w", key, err)
}
}
return s.Storage.Remove(id)
}
func (s *s3Storage) publish(cache *Cache) error {
data, err := json.Marshal(cache)
if err != nil {
return err
}
if _, err := s.call(context.Background(), http.MethodPut, s.metadataKey(cache.ID), nil, bytes.NewReader(data), nil); err != nil {
return fmt.Errorf("publish cache to S3: %w", err)
}
return nil
}
func (s *s3Storage) published(ctx context.Context) (map[uint64]bool, error) {
ids := make(map[uint64]bool)
query := url.Values{"list-type": {"2"}, "prefix": {s.metadataDir()}}
for {
var page struct {
Contents []struct{ Key string }
IsTruncated bool
NextContinuationToken string
}
if _, err := s.call(ctx, http.MethodGet, "", query, nil, &page); err != nil {
return nil, fmt.Errorf("list S3 cache metadata: %w", err)
}
for _, object := range page.Contents {
if id, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimPrefix(object.Key, s.metadataDir()), ".json"), 10, 64); err == nil {
ids[id] = true
}
}
if !page.IsTruncated {
return ids, nil
}
query.Set("continuation-token", page.NextContinuationToken)
}
}
func (s *s3Storage) fetch(ctx context.Context, id uint64) (*Cache, error) {
resp, err := s.do(ctx, http.MethodGet, s.metadataKey(id), nil, nil, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
cache := &Cache{}
if err := json.UnmarshalRead(resp.Body, cache); err != nil {
return nil, fmt.Errorf("read S3 cache metadata %d: %w", id, err)
}
return cache, nil
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHandler_S3SharesEntriesAndDropsExpiredOnes(t *testing.T) {
t.Parallel()
var objects sync.Map
var uploads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
key := strings.TrimPrefix(request.URL.Path, "/cache/")
switch {
case request.Method == http.MethodPut:
if strings.Contains(key, "/blobs/") {
uploads.Add(1)
}
body, err := io.ReadAll(request.Body)
assert.NoError(t, err)
objects.Store(key, body)
case request.URL.Query().Has("list-type"):
_, _ = io.WriteString(writer, "<ListBucketResult>")
objects.Range(func(stored, _ any) bool {
_, _ = io.WriteString(writer, "<Contents><Key>"+stored.(string)+"</Key></Contents>")
return true
})
_, _ = io.WriteString(writer, "</ListBucketResult>")
default:
if body, ok := objects.Load(key); ok {
_, _ = writer.Write(body.([]byte))
} else {
writer.WriteHeader(http.StatusNotFound)
}
}
}))
t.Cleanup(server.Close)
opts := Options{S3: &S3Options{Endpoint: server.URL, Bucket: "cache", Prefix: "runners", AccessKey: "key", SecretKey: "secret"}}
saver, restorer := startTestHandler(t, opts), startTestHandler(t, opts)
entry := map[string]any{"key": "deps", "version": "v1"}
started := time.Now()
finalized, _ := saveV2(t, saver, "deps", "v1", []byte("archive"))
require.Equal(t, true, finalized["ok"])
assert.EqualValues(t, 1, uploads.Load())
id, err := strconv.ParseUint(finalized["entry_id"].(string), 10, 64)
require.NoError(t, err)
cache, err := saver.s3.fetch(t.Context(), id)
require.NoError(t, err)
assert.WithinRange(t, time.Unix(cache.CreatedAt, int64(cache.CreatedAtNanos)), started, time.Now())
require.NoError(t, restorer.importMetadata(t.Context()))
found := v2Call(t, restorer, testClient, "GetCacheEntryDownloadURL", entry)
require.Equal(t, true, found["ok"])
assert.Equal(t, []byte("archive"), getURL(t, found["signed_download_url"].(string)))
for _, cache := range []*Cache{
{ID: 2, Repo: testRepo, Key: "deps-old", Version: "v1", Complete: true, CreatedAt: cache.CreatedAt},
{ID: 1, Repo: testRepo, Key: "deps-new", Version: "v1", Complete: true, CreatedAt: cache.CreatedAt, CreatedAtNanos: 1},
} {
require.NoError(t, saver.s3.publish(cache))
objects.Store(saver.s3.blobKey(cache.ID), []byte(cache.Key))
}
require.NoError(t, restorer.importMetadata(t.Context()))
found = v2Call(t, restorer, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-", "version": "v1"})
require.Equal(t, true, found["ok"])
assert.Equal(t, "deps-new", found["matched_key"])
assert.Equal(t, []byte("deps-new"), getURL(t, found["signed_download_url"].(string)))
objects.Clear()
require.NoError(t, restorer.importMetadata(t.Context()))
assert.Equal(t, true, v2Call(t, restorer, testClient, "CreateCacheEntry", entry)["ok"])
}
type s3TestTransport func(*http.Request) (*http.Response, error)
func (transport s3TestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return transport(req)
}
func TestS3StorageUploadsLargeObjectsInPartsAndAbortsOnFailure(t *testing.T) {
t.Parallel()
for _, failingPart := range []string{"", "2"} {
var parts, aborts atomic.Int32
var completion struct {
Parts []s3Part `xml:"Part"`
}
storage := &s3Storage{bucketURL: httptest.NewRequest(http.MethodGet, "http://cache.local/bucket", nil).URL}
storage.client = &http.Client{Transport: s3TestTransport(func(req *http.Request) (*http.Response, error) {
query, response := req.URL.Query(), &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: http.NoBody}
switch {
case query.Has("uploads"):
response.Body = io.NopCloser(strings.NewReader("<InitiateMultipartUploadResult><UploadId>upload</UploadId></InitiateMultipartUploadResult>"))
case req.Method == http.MethodPut:
parts.Add(1)
response.Header.Set("ETag", "etag-"+query.Get("partNumber"))
if query.Get("partNumber") == failingPart {
response.StatusCode, response.Body = http.StatusBadRequest, io.NopCloser(strings.NewReader("<Error><Code>InvalidPart</Code></Error>"))
}
case req.Method == http.MethodPost:
require.NoError(t, xml.NewDecoder(req.Body).Decode(&completion))
response.Body = io.NopCloser(strings.NewReader("<CompleteMultipartUploadResult/>"))
case req.Method == http.MethodDelete:
aborts.Add(1)
}
return response, nil
})}
err := storage.upload(t.Context(), "blobs/1", strings.NewReader(""), 2*int64(s3PartSize)+1)
assert.EqualValues(t, 3, parts.Load())
if failingPart == "" {
require.NoError(t, err)
assert.Equal(t, []s3Part{{1, "etag-1"}, {2, "etag-2"}, {3, "etag-3"}}, completion.Parts)
assert.Zero(t, aborts.Load())
} else {
require.ErrorContains(t, err, "InvalidPart")
assert.EqualValues(t, 1, aborts.Load())
}
}
}
func TestS3StorageSignsLikeAWS(t *testing.T) {
t.Parallel()
storage := &s3Storage{opts: S3Options{Region: "us-east-1", AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}}
req := httptest.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", nil)
req.Header.Set("X-Amz-Content-Sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
storage.sign(req, time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC))
assert.Equal(t, "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date,Signature=34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7", req.Header.Get("Authorization"))
}
+98 -35
View File
@@ -5,12 +5,18 @@
package artifactcache package artifactcache
import ( import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
) )
type Storage struct { type Storage struct {
@@ -27,17 +33,18 @@ func NewStorage(rootDir string) (*Storage, error) {
} }
func (s *Storage) Exist(id uint64) (bool, error) { func (s *Storage) Exist(id uint64) (bool, error) {
name := s.filename(id) _, err := os.Stat(s.filename(id))
if _, err := os.Stat(name); os.IsNotExist(err) { if os.IsNotExist(err) {
return false, nil return false, nil
} else if err != nil {
return false, err
} }
return true, nil return err == nil, err
} }
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error { func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
name := s.tempName(id, offset) return s.writeFile(s.tempName(id, offset), reader)
}
func (s *Storage) writeFile(name string, reader io.Reader) error {
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return err return err
} }
@@ -45,16 +52,47 @@ func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
if err != nil { if err != nil {
return err return err
} }
defer file.Close()
_, err = io.Copy(file, reader) _, err = io.Copy(file, reader)
if err = errors.Join(err, file.Close()); err != nil {
_ = os.Remove(name)
}
return err return err
} }
func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error {
return s.writeFile(s.blockName(id, blockID), reader)
}
// OrderBlocks renames the staged blocks into the order the block list gives. A block the list
// does not name keeps its staged name, which is how Commit leaves it out, as Azure drops it. One
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
// never does, so no rename can collide with a block not yet moved.
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
wanted, err := json.Marshal(blockIDs) // a block id is client-chosen, so it cannot be a separator
if err != nil {
return err
}
list := filepath.Join(s.tempDir(id), blockFilePrefix+"list")
if recorded, err := os.ReadFile(list); err == nil {
// A retry repeats the list it already sent; another one would reorder what is now staged.
if !bytes.Equal(recorded, wanted) {
return fmt.Errorf("cache %d was already assembled from a different block list", id)
}
return nil
}
for i, blockID := range blockIDs {
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("block %q of cache %d was never uploaded: %w", blockID, id, err)
}
return err
}
}
return os.WriteFile(list, wanted, 0o600)
}
func (s *Storage) Commit(id uint64, size int64) (int64, error) { func (s *Storage) Commit(id uint64, size int64) (int64, error) {
defer func() { defer func() { _ = os.RemoveAll(s.tempDir(id)) }()
_ = os.RemoveAll(s.tempDir(id))
}()
name := s.filename(id) name := s.filename(id)
tempNames, err := s.tempNames(id) tempNames, err := s.tempNames(id)
@@ -65,46 +103,60 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return 0, err return 0, err
} }
file, err := os.Create(name) written, err := assemble(name, tempNames)
if err == nil && size >= 0 && written != size { // old clients like actions/cache@v2 send no size
err = fmt.Errorf("broken file: %v != %v", written, size)
}
if err != nil {
_ = os.Remove(name)
return 0, err
}
return written, nil
}
// assemble appends the parts into the first, deleting each once copied, so it never needs twice the space.
func assemble(name string, tempNames []string) (int64, error) {
if len(tempNames) == 0 {
return 0, errors.New("nothing was uploaded")
}
if err := os.Rename(tempNames[0], name); err != nil {
return 0, err
}
file, err := os.OpenFile(name, os.O_WRONLY|os.O_APPEND, 0)
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer file.Close() defer file.Close()
for _, v := range tempNames[1:] {
var written int64 part, err := os.Open(v)
for _, v := range tempNames {
f, err := os.Open(v)
if err != nil { if err != nil {
return 0, err return 0, err
} }
n, err := io.Copy(file, f) _, err = io.Copy(file, part)
_ = f.Close() _ = part.Close()
if err != nil { if err != nil {
return 0, err return 0, err
} }
written += n _ = os.Remove(v)
} }
info, err := file.Stat()
// If size is less than 0, it means the size is unknown. if err != nil {
// We can't check the size of the file, just skip the check. return 0, err
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
if size >= 0 && written != size {
_ = file.Close()
_ = os.Remove(name)
return 0, fmt.Errorf("broken file: %v != %v", written, size)
} }
return info.Size(), file.Close()
return written, nil
} }
func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) { func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
name := s.filename(id) http.ServeFile(w, r, s.filename(id))
http.ServeFile(w, r, name)
} }
func (s *Storage) Remove(id uint64) { // Remove deletes an entry's blob and any staged parts. It reports failure so the caller can
_ = os.Remove(s.filename(id)) // keep the entry and retry, rather than dropping the only reference to bytes on disk.
_ = os.RemoveAll(s.tempDir(id)) func (s *Storage) Remove(id uint64) error {
if err := os.Remove(s.filename(id)); err != nil && !os.IsNotExist(err) {
return err
}
return os.RemoveAll(s.tempDir(id))
} }
func (s *Storage) filename(id uint64) string { func (s *Storage) filename(id uint64) string {
@@ -119,6 +171,17 @@ func (s *Storage) tempName(id uint64, offset int64) string {
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset)) return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
} }
// blockFilePrefix marks a staged, not yet ordered block, so that tempNames can keep it out of
// Commit's name-ordered concatenation.
const blockFilePrefix = "block-"
func (s *Storage) blockName(id uint64, blockID string) string {
// The block id is client-chosen (base64), so it is hashed rather than trusted as a
// path element.
sum := sha256.Sum256([]byte(blockID))
return filepath.Join(s.tempDir(id), blockFilePrefix+hex.EncodeToString(sum[:]))
}
func (s *Storage) tempNames(id uint64) ([]string, error) { func (s *Storage) tempNames(id uint64) ([]string, error) {
dir := s.tempDir(id) dir := s.tempDir(id)
files, err := os.ReadDir(dir) files, err := os.ReadDir(dir)
@@ -127,7 +190,7 @@ func (s *Storage) tempNames(id uint64) ([]string, error) {
} }
var names []string var names []string
for _, v := range files { for _, v := range files {
if !v.IsDir() { if !v.IsDir() && !strings.HasPrefix(v.Name(), blockFilePrefix) {
names = append(names, filepath.Join(dir, v.Name())) names = append(names, filepath.Join(dir, v.Name()))
} }
} }
+65 -135
View File
@@ -6,20 +6,20 @@ package artifacts
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"github.com/julienschmidt/httprouter"
) )
type FileContainerResourceURL struct { type FileContainerResourceURL struct {
@@ -50,140 +50,87 @@ type ResponseMessage struct {
Message string `json:"message"` Message string `json:"message"`
} }
type WritableFile interface {
io.WriteCloser
}
type WriteFS interface {
OpenWritable(name string) (WritableFile, error)
OpenAppendable(name string) (WritableFile, error)
}
type readWriteFSImpl struct{}
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
return os.Open(name)
}
func (fwfs readWriteFSImpl) OpenWritable(name string) (WritableFile, error) {
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
return nil, err
}
return os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644)
}
func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
return nil, err
}
file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
_, err = file.Seek(0, io.SeekEnd)
if err != nil {
return nil, err
}
return file, nil
}
var gzipExtension = ".gz__" var gzipExtension = ".gz__"
func safeResolve(baseDir, relPath string) string { func safeResolve(baseDir, relPath string) string {
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath))) return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
} }
func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) { func writeJSON(w http.ResponseWriter, value any) {
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { data, err := json.Marshal(value)
runID := params.ByName("runId")
json, err := json.Marshal(FileContainerResourceURL{
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
})
if err != nil { if err != nil {
panic(err) panic(err)
} }
if _, err := w.Write(data); err != nil {
_, err = w.Write(json)
if err != nil {
panic(err) panic(err)
} }
}
func uploads(router *http.ServeMux, baseDir string) {
router.HandleFunc("POST /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) {
writeJSON(w, FileContainerResourceURL{
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, req.PathValue("runId")),
})
}) })
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { router.HandleFunc("PUT /upload/{runId}", func(w http.ResponseWriter, req *http.Request) {
itemPath := req.URL.Query().Get("itemPath") itemPath := req.URL.Query().Get("itemPath")
runID := params.ByName("runId")
if req.Header.Get("Content-Encoding") == "gzip" { if req.Header.Get("Content-Encoding") == "gzip" {
itemPath += gzipExtension itemPath += gzipExtension
} }
safeRunPath := safeResolve(baseDir, runID) safePath := safeResolve(safeResolve(baseDir, req.PathValue("runId")), itemPath)
safePath := safeResolve(safeRunPath, itemPath)
file, err := func() (WritableFile, error) { if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
contentRange := req.Header.Get("Content-Range") panic(err)
if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
return fsys.OpenAppendable(safePath)
} }
return fsys.OpenWritable(safePath) var start int64
}() if contentRange := req.Header.Get("Content-Range"); contentRange != "" {
if _, err := fmt.Sscanf(contentRange, "bytes %d-", &start); err != nil || start < 0 {
http.Error(w, "invalid content range", http.StatusBadRequest)
return
}
}
flags := os.O_CREATE | os.O_WRONLY
if start == 0 {
flags |= os.O_TRUNC
}
file, err := os.OpenFile(safePath, flags, 0o644)
if err != nil { if err != nil {
panic(err) panic(err)
} }
defer file.Close() defer file.Close()
if _, err := file.Seek(start, io.SeekStart); err != nil {
writer, ok := file.(io.Writer) panic(err)
if !ok {
panic(errors.New("File is not writable"))
} }
if req.Body == nil { if req.Body == nil {
panic(errors.New("No body given")) panic(errors.New("no body given"))
} }
_, err = io.Copy(writer, req.Body) _, err = io.Copy(file, req.Body)
if err != nil { if err != nil {
panic(err) panic(err)
} }
json, err := json.Marshal(ResponseMessage{ writeJSON(w, ResponseMessage{
Message: "success", Message: "success",
}) })
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
}) })
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { router.HandleFunc("PATCH /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, _req *http.Request) {
json, err := json.Marshal(ResponseMessage{ writeJSON(w, ResponseMessage{
Message: "success", Message: "success",
}) })
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
}) })
} }
func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) { func downloads(router *http.ServeMux, baseDir string) {
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { router.HandleFunc("GET /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) {
runID := params.ByName("runId") runID := req.PathValue("runId")
safePath := safeResolve(baseDir, runID) entries, err := os.ReadDir(safeResolve(baseDir, runID))
if err != nil && !os.IsNotExist(err) {
entries, err := fs.ReadDir(fsys, safePath)
if err != nil {
panic(err) panic(err)
} }
@@ -195,27 +142,22 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
}) })
} }
json, err := json.Marshal(NamedFileContainerResourceURLResponse{ writeJSON(w, NamedFileContainerResourceURLResponse{
Count: len(list), Count: len(list),
Value: list, Value: list,
}) })
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
}) })
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { router.HandleFunc("GET /download/{container}", func(w http.ResponseWriter, req *http.Request) {
container := params.ByName("container") container := req.PathValue("container")
itemPath := req.URL.Query().Get("itemPath") itemPath := req.URL.Query().Get("itemPath")
safePath := safeResolve(baseDir, filepath.Join(container, itemPath)) safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
var files []ContainerItem var files []ContainerItem
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error { err := filepath.WalkDir(safePath, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() { if !entry.IsDir() {
rel, err := filepath.Rel(safePath, path) rel, err := filepath.Rel(safePath, path)
if err != nil { if err != nil {
@@ -224,50 +166,37 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
// if it was upload as gzip // if it was upload as gzip
rel = strings.TrimSuffix(rel, gzipExtension) rel = strings.TrimSuffix(rel, gzipExtension)
path := filepath.Join(itemPath, rel)
rel = filepath.ToSlash(rel)
path = filepath.ToSlash(path)
files = append(files, ContainerItem{ files = append(files, ContainerItem{
Path: path, Path: filepath.ToSlash(filepath.Join(itemPath, rel)),
ItemType: "file", ItemType: "file",
ContentLocation: fmt.Sprintf("http://%s/artifact/%s/%s/%s", req.Host, container, itemPath, rel), ContentLocation: (&url.URL{Scheme: "http", Host: req.Host, Path: "/artifact/" + container + "/" + itemPath + "/" + filepath.ToSlash(rel)}).String(),
}) })
} }
return nil return nil
}) })
if err != nil { if err != nil && !os.IsNotExist(err) {
panic(err) panic(err)
} }
json, err := json.Marshal(ContainerItemResponse{ writeJSON(w, ContainerItemResponse{
Value: files, Value: files,
}) })
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
}) })
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { router.HandleFunc("GET /artifact/{path...}", func(w http.ResponseWriter, req *http.Request) {
path := params.ByName("path")[1:] safePath := safeResolve(baseDir, req.PathValue("path"))
safePath := safeResolve(baseDir, path) file, err := os.Open(safePath)
file, err := fsys.Open(safePath)
if err != nil { if err != nil {
// try gzip file // try gzip file
file, err = fsys.Open(safePath + gzipExtension) file, err = os.Open(safePath + gzipExtension)
if err != nil { if err != nil {
panic(err) panic(err)
} }
w.Header().Add("Content-Encoding", "gzip") w.Header().Add("Content-Encoding", "gzip")
} }
defer file.Close()
_, err = io.Copy(w, file) _, err = io.Copy(w, file)
if err != nil { if err != nil {
@@ -284,24 +213,25 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
return cancel return cancel
} }
router := httprouter.New() router := http.NewServeMux()
logger.Debugf("Artifacts base path '%s'", artifactPath) logger.Debugf("Artifacts base path '%s'", artifactPath)
fsys := readWriteFSImpl{} uploads(router, artifactPath)
uploads(router, artifactPath, fsys) downloads(router, artifactPath)
downloads(router, artifactPath, fsys) registerV4(router, artifactPath)
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf("%s:%s", addr, port), Addr: net.JoinHostPort(addr, port),
ReadHeaderTimeout: 2 * time.Second, ReadHeaderTimeout: 2 * time.Second,
Handler: router, Handler: router,
} }
// run server // run server
go func() { go func() {
logger.Infof("Start server on http://%s:%s", addr, port) logger.Infof("Start server on http://%s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(err) logger.Error(err)
os.Exit(1)
} }
}() }()
File diff suppressed because it is too large. Load diff
+329
View File
@@ -0,0 +1,329 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifacts
import (
"cmp"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"encoding/xml"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
type serverV4 struct { // the subset of Gitea's v4 artifact service that the toolkit uses, unauthenticated like the v3 routes beside it
dir string
mu sync.Mutex
lastID int64
created map[string]bool
}
const (
zipMimeType = "application/zip"
maxTwirpBytes = 1 << 20
)
func registerV4(router *http.ServeMux, baseDir string) {
server := &serverV4{dir: filepath.Join(baseDir, "v4"), lastID: time.Now().UnixMilli(), created: map[string]bool{}} // IDs stay unique across invocations sharing baseDir
router.HandleFunc("POST "+artifactServicePath+"/CreateArtifact", twirp(server.createArtifact))
router.HandleFunc("POST "+artifactServicePath+"/FinalizeArtifact", twirp(server.finalizeArtifact))
router.HandleFunc("POST "+artifactServicePath+"/ListArtifacts", twirp(server.listArtifacts))
router.HandleFunc("POST "+artifactServicePath+"/GetSignedArtifactURL", twirp(server.getSignedArtifactURL))
router.HandleFunc("POST "+artifactServicePath+"/DeleteArtifact", twirp(server.deleteArtifact))
router.HandleFunc("PUT "+artifactServicePath+"/UploadArtifact", server.uploadArtifact)
router.HandleFunc("GET "+artifactServicePath+"/DownloadArtifact", server.downloadArtifact)
}
type twirpError struct {
status int
code string
msg string
}
func (e *twirpError) Error() string { return e.msg }
func notFound(name string) error {
return &twirpError{http.StatusNotFound, "not_found", fmt.Sprintf("artifact %q not found", name)}
}
func badRequest(format string, args ...any) error {
return &twirpError{http.StatusBadRequest, "invalid_argument", fmt.Sprintf(format, args...)}
}
func twirp[Req, Resp any](handle func(*http.Request, Req) (Resp, error)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req Req
var resp Resp
err := json.UnmarshalRead(io.LimitReader(r.Body, maxTwirpBytes), &req, json.MatchCaseInsensitiveNames(true)) // also matches the toolkit's proto field names
if err != nil {
err = badRequest("malformed request: %v", err)
} else {
resp, err = handle(r, req)
}
w.Header().Set("Content-Type", "application/json")
if err != nil {
terr := &twirpError{http.StatusInternalServerError, "internal", err.Error()}
errors.As(err, &terr)
w.WriteHeader(terr.status)
_ = json.MarshalWrite(w, map[string]string{"code": terr.code, "msg": terr.msg})
return
}
_ = json.MarshalWrite(w, resp)
}
}
func fileName(name string) string { // safe on any filesystem for any client-chosen name, case-insensitive ones included
sum := sha256.Sum256([]byte(name))
return hex.EncodeToString(sum[:])
}
func (s *serverV4) runDir(runID string) string {
return filepath.Join(s.dir, fileName(runID))
}
func (s *serverV4) artifactDir(runID, name string) string {
return filepath.Join(s.runDir(runID), fileName(name))
}
func (s *serverV4) urlDir(r *http.Request) string {
return s.artifactDir(r.URL.Query().Get("run"), r.URL.Query().Get("name"))
}
func readMeta(dir string) (*monolithArtifact, error) {
data, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
return nil, err
}
var meta monolithArtifact
return &meta, json.Unmarshal(data, &meta)
}
func writeMeta(dir string, meta *monolithArtifact) error {
data, err := json.Marshal(meta)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "meta.json"), data, 0o644)
}
func blobURL(r *http.Request, endpoint, runID, name string) string {
return "http://" + r.Host + artifactServicePath + "/" + endpoint + "?" + url.Values{"run": {runID}, "name": {name}}.Encode()
}
func (s *serverV4) createArtifact(r *http.Request, req createArtifactRequest) (createArtifactResponse, error) { // an exec invocation re-runs its workflows, so it replaces earlier invocations' artifacts but conflicts with its own
dir := s.artifactDir(req.WorkflowRunBackendID, req.Name)
s.mu.Lock()
defer s.mu.Unlock()
if s.created[dir] {
return createArtifactResponse{}, &twirpError{http.StatusConflict, "already_exists", "an artifact with this name already exists on the workflow run"}
}
if err := os.RemoveAll(dir); err != nil {
return createArtifactResponse{}, err
}
if err := os.MkdirAll(filepath.Join(dir, "blocks"), 0o755); err != nil {
return createArtifactResponse{}, err
}
s.lastID++
if err := writeMeta(dir, &monolithArtifact{
WorkflowRunBackendID: req.WorkflowRunBackendID,
DatabaseID: s.lastID,
Name: req.Name,
CreatedAt: time.Now().UTC().Truncate(time.Second),
}); err != nil {
return createArtifactResponse{}, err
}
s.created[dir] = true
return createArtifactResponse{Ok: true, SignedUploadURL: blobURL(r, "UploadArtifact", req.WorkflowRunBackendID, req.Name)}, nil
}
type blockList struct {
Blocks []string `xml:",any"` // Latest, Committed and Uncommitted all name a staged block
}
func (s *serverV4) uploadArtifact(w http.ResponseWriter, r *http.Request) {
dir := s.urlDir(r)
if meta, err := readMeta(dir); err != nil || meta.Digest != "" {
http.Error(w, "no artifact awaits an upload", http.StatusNotFound)
return
}
contentType := r.Header.Get("x-ms-blob-content-type")
var err error
switch comp := r.URL.Query().Get("comp"); comp {
case "block":
err = writeFile(filepath.Join(dir, "blocks", fileName(r.URL.Query().Get("blockid"))), r.Body)
case "blocklist":
err = commitBlob(dir, contentType, func(out io.Writer) error { return joinBlocks(out, dir, r.Body) })
case "": // Put Blob, which docker/build-push-action uses
err = commitBlob(dir, contentType, func(out io.Writer) error {
_, err := io.Copy(out, r.Body)
return err
})
default:
http.Error(w, "unsupported comp "+comp, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func writeFile(name string, body io.Reader) error {
file, err := os.Create(name)
if err != nil {
return err
}
_, err = io.Copy(file, body)
return errors.Join(err, file.Close())
}
func commitBlob(dir, contentType string, write func(io.Writer) error) error { // replaces the blob whole and keeps its content type, as Azure does
out, err := os.CreateTemp(dir, "blob")
if err != nil {
return err
}
if err = errors.Join(write(out), out.Close()); err == nil {
err = os.Rename(out.Name(), filepath.Join(dir, "blob"))
}
if err != nil {
return errors.Join(err, os.Remove(out.Name()))
}
return os.WriteFile(filepath.Join(dir, "content-type"), []byte(contentType), 0o644)
}
func joinBlocks(out io.Writer, dir string, body io.Reader) error { // keeps the blocks for finalizeArtifact, so that a retried commit can join them again
var list blockList
if err := xml.NewDecoder(io.LimitReader(body, maxTwirpBytes)).Decode(&list); err != nil {
return err
}
for _, id := range list.Blocks {
if err := appendBlock(out, filepath.Join(dir, "blocks", fileName(id))); err != nil {
return err
}
}
return nil
}
func appendBlock(out io.Writer, name string) error {
in, err := os.Open(name)
if err != nil {
return err
}
defer in.Close()
_, err = io.Copy(out, in)
return err
}
func (s *serverV4) finalizeArtifact(_ *http.Request, req finalizeArtifactRequest) (finalizeArtifactResponse, error) {
dir := s.artifactDir(req.WorkflowRunBackendID, req.Name)
meta, err := readMeta(dir)
if err != nil {
return finalizeArtifactResponse{}, notFound(req.Name)
} else if meta.Digest != "" {
return finalizeArtifactResponse{Ok: true, ArtifactID: meta.DatabaseID}, nil
}
blob, err := os.Open(filepath.Join(dir, "blob"))
if err != nil {
return finalizeArtifactResponse{}, badRequest("artifact %q has no upload", req.Name)
}
defer blob.Close()
hash := sha256.New()
size, err := io.Copy(hash, blob)
if err != nil {
return finalizeArtifactResponse{}, err
}
digest := "sha256:" + hex.EncodeToString(hash.Sum(nil))
if size != req.Size {
return finalizeArtifactResponse{}, badRequest("size %d does not match the %d bytes uploaded", req.Size, size)
}
if req.Hash != "" && req.Hash != digest {
return finalizeArtifactResponse{}, badRequest("hash %s does not match the uploaded %s", req.Hash, digest)
}
s.mu.Lock() // only after hashing, so finalizing one large artifact does not stall the others
defer s.mu.Unlock()
if current, err := readMeta(dir); err != nil || current.DatabaseID != meta.DatabaseID {
return finalizeArtifactResponse{}, notFound(req.Name)
}
meta.Size, meta.Digest = size, digest
if err := writeMeta(dir, meta); err != nil {
return finalizeArtifactResponse{}, err
}
return finalizeArtifactResponse{Ok: true, ArtifactID: meta.DatabaseID}, os.RemoveAll(filepath.Join(dir, "blocks"))
}
func (s *serverV4) listArtifacts(_ *http.Request, req listArtifactsRequest) (listArtifactsResponse, error) {
run := s.runDir(req.WorkflowRunBackendID)
entries, err := os.ReadDir(run)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return listArtifactsResponse{}, err
}
var resp listArtifactsResponse
for _, entry := range entries {
meta, err := readMeta(filepath.Join(run, entry.Name()))
if err != nil || meta.Digest == "" ||
req.NameFilter != "" && req.NameFilter != meta.Name || req.IDFilter != 0 && req.IDFilter != meta.DatabaseID {
continue
}
resp.Artifacts = append(resp.Artifacts, *meta)
}
slices.SortFunc(resp.Artifacts, func(left, right monolithArtifact) int { return cmp.Compare(left.DatabaseID, right.DatabaseID) })
return resp, nil
}
func (s *serverV4) getSignedArtifactURL(r *http.Request, req getSignedArtifactURLRequest) (getSignedArtifactURLResponse, error) {
if meta, err := readMeta(s.artifactDir(req.WorkflowRunBackendID, req.Name)); err != nil || meta.Digest == "" {
return getSignedArtifactURLResponse{}, notFound(req.Name)
}
return getSignedArtifactURLResponse{SignedURL: blobURL(r, "DownloadArtifact", req.WorkflowRunBackendID, req.Name)}, nil
}
func (s *serverV4) downloadArtifact(w http.ResponseWriter, r *http.Request) {
dir := s.urlDir(r)
meta, err := readMeta(dir)
if err != nil || meta.Digest == "" {
http.Error(w, "artifact not found", http.StatusNotFound)
return
}
blob, err := os.Open(filepath.Join(dir, "blob"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer blob.Close()
data, _ := os.ReadFile(filepath.Join(dir, "content-type"))
contentType, filename := string(data), meta.Name
if !strings.Contains(contentType, "/") {
contentType = zipMimeType
}
if contentType == zipMimeType && !strings.HasSuffix(filename, ".zip") {
filename += ".zip"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+strings.ReplaceAll(url.QueryEscape(filename), "+", "%20")) // RFC 5987 attr-chars only, which the toolkit and mime.ParseMediaType both accept
http.ServeContent(w, r, "", meta.CreatedAt, blob)
}
func (s *serverV4) deleteArtifact(_ *http.Request, req deleteArtifactRequest) (deleteArtifactResponse, error) {
dir := s.artifactDir(req.WorkflowRunBackendID, req.Name)
s.mu.Lock()
defer s.mu.Unlock()
meta, err := readMeta(dir)
if err != nil {
return deleteArtifactResponse{}, notFound(req.Name)
}
delete(s.created, dir)
return deleteArtifactResponse{Ok: true, ArtifactID: meta.DatabaseID}, os.RemoveAll(dir)
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifacts
import (
"crypto/rand"
"encoding/base64"
"encoding/json/v2"
"time"
)
func LocalRuntimeToken(run string, lifetime time.Duration) string { // an unsecured JWT with the claims the toolkit and buildx read, its run hashed for scp and its jti an unguessable bearer
now := time.Now()
claims, _ := json.Marshal(map[string]any{
"scp": "Actions.Results:" + fileName(run) + ":1",
"ac": `[{"Scope":"","Permission":3}]`,
"nbf": now.Unix(),
"exp": now.Add(lifetime).Unix(),
"jti": rand.Text(),
})
return base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + "." + base64.RawURLEncoding.EncodeToString(claims) + "."
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifacts
import "time"
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService"
type ( // the ArtifactService messages in the protobuf JSON mapping, which writes int64 as strings
createArtifactResponse struct {
Ok bool `json:"ok"`
SignedUploadURL string `json:"signedUploadUrl"`
}
finalizeArtifactRequest struct {
WorkflowRunBackendID string
Name string
Size int64 `json:",string"`
Hash string
}
finalizeArtifactResponse struct {
Ok bool `json:"ok"`
ArtifactID int64 `json:"artifactId,string"`
}
listArtifactsRequest struct {
WorkflowRunBackendID string
NameFilter string
IDFilter int64 `json:",string"`
}
listArtifactsResponse struct {
Artifacts []monolithArtifact `json:"artifacts"`
}
monolithArtifact struct {
WorkflowRunBackendID string `json:"workflowRunBackendId"` // the toolkit sends it back to GetSignedArtifactURL
DatabaseID int64 `json:"databaseId,string"`
Name string `json:"name"`
Size int64 `json:"size,string"`
CreatedAt time.Time `json:"createdAt"` // whole seconds, the toolkit rejects the trimmed fractions json writes
Digest string `json:"digest"` // empty until the upload is finalized
}
getSignedArtifactURLRequest struct {
WorkflowRunBackendID string
Name string
}
getSignedArtifactURLResponse struct {
SignedURL string `json:"signedUrl"`
}
createArtifactRequest = getSignedArtifactURLRequest
deleteArtifactRequest = getSignedArtifactURLRequest
deleteArtifactResponse = finalizeArtifactResponse
)
-60
View File
@@ -1,60 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import "slices"
// CartesianProduct takes map of lists and returns list of unique tuples
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
listNames := make([]string, 0)
lists := make([][]any, 0)
for k, v := range mapOfLists {
listNames = append(listNames, k)
lists = append(lists, v)
}
listCart := cartN(lists...)
rtn := make([]map[string]any, 0)
for _, list := range listCart {
vMap := make(map[string]any)
for i, v := range list {
vMap[listNames[i]] = v
}
rtn = append(rtn, vMap)
}
return rtn
}
func cartN(a ...[]any) [][]any {
c := 1
for _, a := range a {
c *= len(a)
}
if c == 0 || len(a) == 0 {
return nil
}
p := make([][]any, c)
b := make([]any, c*len(a))
n := make([]int, len(a))
s := 0
for i := range p {
e := s + len(a)
pi := b[s:e]
p[i] = pi
s = e
for j, n := range n {
pi[j] = a[j][n]
}
for j := range slices.Backward(n) {
n[j]++
if n[j] < len(a[j]) {
break
}
n[j] = 0
}
}
return p
}
-43
View File
@@ -1,43 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCartesianProduct(t *testing.T) {
assert := assert.New(t)
input := map[string][]any{
"foo": {1, 2, 3, 4},
"bar": {"a", "b", "c"},
"baz": {false, true},
}
output := CartesianProduct(input)
assert.Len(output, 24)
for _, v := range output {
assert.Len(v, 3)
assert.Contains(v, "foo")
assert.Contains(v, "bar")
assert.Contains(v, "baz")
}
input = map[string][]any{
"foo": {1, 2, 3, 4},
"bar": {},
"baz": {false, true},
}
output = CartesianProduct(input)
assert.Empty(output)
input = map[string][]any{}
output = CartesianProduct(input)
assert.Empty(output)
}
+18 -25
View File
@@ -4,15 +4,15 @@
package common package common
import ( import (
"context"
"errors" "errors"
"testing" "testing"
"github.com/sirupsen/logrus" "gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest"
) )
func TestDryrunContext(t *testing.T) { func TestDryrunContext(t *testing.T) {
ctx := context.Background() ctx := t.Context()
if Dryrun(ctx) { if Dryrun(ctx) {
t.Fatal("plain context should not be dryrun") t.Fatal("plain context should not be dryrun")
} }
@@ -25,7 +25,7 @@ func TestDryrunContext(t *testing.T) {
} }
func TestJobErrorContainer(t *testing.T) { func TestJobErrorContainer(t *testing.T) {
ctx := context.Background() ctx := t.Context()
err := errors.New("job failed") err := errors.New("job failed")
SetJobError(ctx, err) SetJobError(ctx, err)
@@ -40,34 +40,27 @@ func TestJobErrorContainer(t *testing.T) {
} }
} }
func TestLoggerAndHookContext(t *testing.T) { func TestLoggerAndHandlerContext(t *testing.T) {
ctx := context.Background() ctx := t.Context()
if Logger(ctx) != logrus.StandardLogger() { if Logger(ctx) != logging.Default() {
t.Fatal("plain context should use standard logger") t.Fatal("plain context should use the default logger")
} }
if LoggerHook(ctx) != nil { if LoggerHandler(ctx) != nil {
t.Fatal("plain context should not have a logger hook") t.Fatal("plain context should not have a logger handler")
}
if StepIDs(ctx) != nil {
t.Fatal("plain context should not have step IDs")
} }
logger := logrus.New() logger := logging.New(logtest.New())
ctx = WithLogger(ctx, logger) ctx = WithLogger(ctx, logger)
if Logger(ctx) != logger { if Logger(ctx) != logger {
t.Fatal("WithLogger should set logger") t.Fatal("WithLogger should set logger")
} }
hook := testHook{} handler := logtest.New()
ctx = WithLoggerHook(ctx, hook) ctx = WithLoggerHandler(ctx, handler)
if LoggerHook(ctx) != hook { if LoggerHandler(ctx) != handler {
t.Fatal("WithLoggerHook should set hook") t.Fatal("WithLoggerHandler should set handler")
} }
} }
type testHook struct{}
func (testHook) Levels() []logrus.Level {
return logrus.AllLevels
}
func (testHook) Fire(*logrus.Entry) error {
return nil
}
+1 -6
View File
@@ -14,13 +14,8 @@ const dryrunContextKeyVal = dryrunContextKey("dryrun")
// Dryrun returns true if the current context is dryrun // Dryrun returns true if the current context is dryrun
func Dryrun(ctx context.Context) bool { func Dryrun(ctx context.Context) bool {
val := ctx.Value(dryrunContextKeyVal) dryrun, _ := ctx.Value(dryrunContextKeyVal).(bool)
if val != nil {
if dryrun, ok := val.(bool); ok {
return dryrun return dryrun
}
}
return false
} }
// WithDryrun adds a value to the context for dryrun // WithDryrun adds a value to the context for dryrun
+49 -52
View File
@@ -7,9 +7,11 @@ package common
import ( import (
"context" "context"
"fmt" "fmt"
"math/rand/v2"
"runtime/debug" "runtime/debug"
"time"
log "github.com/sirupsen/logrus" "gitea.com/gitea/runner/act/common/logging"
) )
// Executor define contract for the steps of a workflow // Executor define contract for the steps of a workflow
@@ -21,8 +23,7 @@ type Conditional func(ctx context.Context) bool
// NewInfoExecutor is an executor that logs messages // NewInfoExecutor is an executor that logs messages
func NewInfoExecutor(format string, args ...any) Executor { func NewInfoExecutor(format string, args ...any) Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := Logger(ctx) Logger(ctx).Infof(format, args...)
logger.Infof(format, args...)
return nil return nil
} }
} }
@@ -30,8 +31,7 @@ func NewInfoExecutor(format string, args ...any) Executor {
// NewDebugExecutor is an executor that logs messages // NewDebugExecutor is an executor that logs messages
func NewDebugExecutor(format string, args ...any) Executor { func NewDebugExecutor(format string, args ...any) Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := Logger(ctx) Logger(ctx).Debugf(format, args...)
logger.Debugf(format, args...)
return nil return nil
} }
} }
@@ -39,9 +39,7 @@ func NewDebugExecutor(format string, args ...any) Executor {
// NewPipelineExecutor creates a new executor from a series of other executors // NewPipelineExecutor creates a new executor from a series of other executors
func NewPipelineExecutor(executors ...Executor) Executor { func NewPipelineExecutor(executors ...Executor) Executor {
if len(executors) == 0 { if len(executors) == 0 {
return func(ctx context.Context) error { return NewErrorExecutor(nil)
return nil
}
} }
var rtn Executor var rtn Executor
for _, executor := range executors { for _, executor := range executors {
@@ -54,22 +52,6 @@ func NewPipelineExecutor(executors ...Executor) Executor {
return rtn return rtn
} }
// NewConditionalExecutor creates a new executor based on conditions
func NewConditionalExecutor(conditional Conditional, trueExecutor, falseExecutor Executor) Executor {
return func(ctx context.Context) error {
if conditional(ctx) {
if trueExecutor != nil {
return trueExecutor(ctx)
}
} else {
if falseExecutor != nil {
return falseExecutor(ctx)
}
}
return nil
}
}
// NewErrorExecutor creates a new executor that always errors out // NewErrorExecutor creates a new executor that always errors out
func NewErrorExecutor(err error) Executor { func NewErrorExecutor(err error) Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
@@ -77,6 +59,13 @@ func NewErrorExecutor(err error) Executor {
} }
} }
func RecoverPanic(err *error) { // defer directly in workers to keep stacks in host logs
if recovered := recover(); recovered != nil {
logging.Errorf("panic: %v\n%s", recovered, debug.Stack())
*err = fmt.Errorf("panic: %v", recovered)
}
}
// NewParallelExecutor creates a new executor from a parallel of other executors // NewParallelExecutor creates a new executor from a parallel of other executors
func NewParallelExecutor(parallel int, executors ...Executor) Executor { func NewParallelExecutor(parallel int, executors ...Executor) Executor {
if len(executors) == 0 { if len(executors) == 0 {
@@ -90,34 +79,29 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
errs := make(chan error, len(executors)) errs := make(chan error, len(executors))
if 1 > parallel { if 1 > parallel {
log.Debugf("Parallel tasks (%d) below minimum, setting to 1", parallel) logging.Debugf("Parallel tasks (%d) below minimum, setting to 1", parallel)
parallel = 1 parallel = 1
} }
log.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors)) logging.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors))
for i := 0; i < parallel; i++ { for workerID := range parallel {
go func(workerID int, work <-chan Executor, errs chan<- error) { go func() {
log.Debugf("Worker %d started", workerID) logging.Debugf("Worker %d started", workerID)
taskCount := 0 taskCount := 0
for executor := range work { for executor := range work {
taskCount++ taskCount++
log.Debugf("Worker %d executing task %d", workerID, taskCount) logging.Debugf("Worker %d executing task %d", workerID, taskCount)
// Recover from panics in executors to avoid crashing the worker // Recover from panics in executors to avoid crashing the worker
// goroutine which would leave the runner process hung. // goroutine which would leave the runner process hung.
// https://gitea.com/gitea/runner/issues/371 // https://gitea.com/gitea/runner/issues/371
errs <- func() (err error) { errs <- func() (err error) {
defer func() { defer RecoverPanic(&err)
if r := recover(); r != nil {
log.Errorf("panic in executor: %v\n%s", r, debug.Stack())
err = fmt.Errorf("panic: %v", r)
}
}()
return executor(ctx) return executor(ctx)
}() }()
} }
log.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount) logging.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount)
}(i, work, errs) }()
} }
for i := range executors { for i := range executors {
@@ -166,12 +150,7 @@ func (e Executor) If(conditional Conditional) Executor {
// IfNot only runs this executor if conditional is true // IfNot only runs this executor if conditional is true
func (e Executor) IfNot(conditional Conditional) Executor { func (e Executor) IfNot(conditional Conditional) Executor {
return func(ctx context.Context) error { return e.If(func(ctx context.Context) bool { return !conditional(ctx) })
if !conditional(ctx) {
return e(ctx)
}
return nil
}
} }
// IfBool only runs this executor if conditional is true // IfBool only runs this executor if conditional is true
@@ -181,21 +160,39 @@ func (e Executor) IfBool(conditional bool) Executor {
}) })
} }
// RetryBackoff is the wait between Retry's attempts, random between 1 and 10 seconds like GitHub's runner.
var RetryBackoff = func() time.Duration { return time.Second + rand.N(9*time.Second) }
// Retry runs the executor up to attempts times until it succeeds.
func (e Executor) Retry(attempts int) Executor {
return func(ctx context.Context) error {
for attempt := 1; ; attempt++ {
err := e(ctx)
if err == nil || attempt == attempts || ctx.Err() != nil {
return err
}
backoff := RetryBackoff()
Logger(ctx).Warnf("%v, retrying in %s", err, backoff.Round(time.Second))
select {
case <-ctx.Done():
return err
case <-time.After(backoff):
}
}
}
}
// Finally adds an executor to run after other executor // Finally adds an executor to run after other executor
func (e Executor) Finally(finally Executor) Executor { func (e Executor) Finally(finally Executor) Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
err := e(ctx) err := e(ctx)
err2 := finally(ctx) err2 := finally(ctx)
if err2 != nil { if err2 != nil {
return fmt.Errorf("Error occurred running finally: %v (original error: %v)", err2, err) if err != nil {
return fmt.Errorf("error occurred running finally: %w (original error: %w)", err2, err)
}
return fmt.Errorf("error occurred running finally: %w (original error: %v)", err2, err)
} }
return err return err
} }
} }
// Not return an inverted conditional
func (c Conditional) Not() Conditional {
return func(ctx context.Context) bool {
return !c(ctx)
}
}
-89
View File
@@ -1,89 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// Simple fast test that verifies max-parallel: 2 limits concurrency
func TestMaxParallel2Quick(t *testing.T) {
ctx := context.Background()
var currentRunning atomic.Int32
var maxSimultaneous atomic.Int32
executors := make([]Executor, 4)
for i := range 4 {
executors[i] = func(ctx context.Context) error {
current := currentRunning.Add(1)
// Update max if needed
for {
maxValue := maxSimultaneous.Load()
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
break
}
}
time.Sleep(10 * time.Millisecond)
currentRunning.Add(-1)
return nil
}
}
err := NewParallelExecutor(2, executors...)(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.LessOrEqual(t, maxSimultaneous.Load(), int32(2),
"Should not exceed max-parallel: 2")
}
// Test that verifies max-parallel: 1 enforces sequential execution
func TestMaxParallel1Sequential(t *testing.T) {
ctx := context.Background()
var currentRunning atomic.Int32
var maxSimultaneous atomic.Int32
var executionOrder []int
var orderMutex sync.Mutex
executors := make([]Executor, 5)
for i := range 5 {
taskID := i
executors[i] = func(ctx context.Context) error {
current := currentRunning.Add(1)
// Track execution order
orderMutex.Lock()
executionOrder = append(executionOrder, taskID)
orderMutex.Unlock()
// Update max if needed
for {
maxValue := maxSimultaneous.Load()
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
break
}
}
time.Sleep(20 * time.Millisecond)
currentRunning.Add(-1)
return nil
}
}
err := NewParallelExecutor(1, executors...)(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, int32(1), maxSimultaneous.Load(),
"max-parallel: 1 should only run 1 task at a time")
assert.Len(t, executionOrder, 5, "All 5 tasks should have executed")
}
@@ -1,221 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestMaxParallelJobExecution tests actual job execution with max-parallel
func TestMaxParallelJobExecution(t *testing.T) {
t.Run("MaxParallel=1 Sequential", func(t *testing.T) {
var currentRunning atomic.Int32
var maxConcurrent int32
var executionOrder []int
var mu sync.Mutex
executors := make([]Executor, 5)
for i := range 5 {
taskID := i
executors[i] = func(ctx context.Context) error {
current := currentRunning.Add(1)
// Track max concurrent
for {
maxValue := atomic.LoadInt32(&maxConcurrent)
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
break
}
}
mu.Lock()
executionOrder = append(executionOrder, taskID)
mu.Unlock()
time.Sleep(10 * time.Millisecond)
currentRunning.Add(-1)
return nil
}
}
ctx := context.Background()
err := NewParallelExecutor(1, executors...)(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, int32(1), maxConcurrent, "Should never exceed 1 concurrent execution")
assert.Len(t, executionOrder, 5, "All tasks should execute")
})
t.Run("MaxParallel=3 Limited", func(t *testing.T) {
var currentRunning atomic.Int32
var maxConcurrent int32
executors := make([]Executor, 10)
for i := range 10 {
executors[i] = func(ctx context.Context) error {
current := currentRunning.Add(1)
for {
maxValue := atomic.LoadInt32(&maxConcurrent)
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
break
}
}
time.Sleep(20 * time.Millisecond)
currentRunning.Add(-1)
return nil
}
}
ctx := context.Background()
err := NewParallelExecutor(3, executors...)(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.LessOrEqual(t, int(maxConcurrent), 3, "Should never exceed 3 concurrent executions")
assert.GreaterOrEqual(t, int(maxConcurrent), 1, "Should have at least 1 concurrent execution")
})
t.Run("MaxParallel=0 Uses1Worker", func(t *testing.T) {
var maxConcurrent int32
var currentRunning atomic.Int32
executors := make([]Executor, 5)
for i := range 5 {
executors[i] = func(ctx context.Context) error {
current := currentRunning.Add(1)
for {
maxValue := atomic.LoadInt32(&maxConcurrent)
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
break
}
}
time.Sleep(10 * time.Millisecond)
currentRunning.Add(-1)
return nil
}
}
ctx := context.Background()
// When maxParallel is 0 or negative, it defaults to 1
err := NewParallelExecutor(0, executors...)(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, int32(1), maxConcurrent, "Should use 1 worker when max-parallel is 0")
})
}
// TestMaxParallelWithErrors tests error handling with max-parallel
func TestMaxParallelWithErrors(t *testing.T) {
t.Run("OneTaskFailsOthersContinue", func(t *testing.T) {
var successCount int32
executors := make([]Executor, 5)
for i := range 5 {
taskID := i
executors[i] = func(ctx context.Context) error {
if taskID == 2 {
return assert.AnError
}
atomic.AddInt32(&successCount, 1)
return nil
}
}
ctx := context.Background()
err := NewParallelExecutor(2, executors...)(ctx)
// Should return the error from task 2
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// Other tasks should still execute
assert.Equal(t, int32(4), successCount, "4 tasks should succeed")
})
t.Run("ContextCancellation", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var startedCount int32
executors := make([]Executor, 10)
for i := range 10 {
executors[i] = func(ctx context.Context) error {
atomic.AddInt32(&startedCount, 1)
time.Sleep(100 * time.Millisecond)
return nil
}
}
// Cancel after a short delay
go func() {
time.Sleep(30 * time.Millisecond)
cancel()
}()
err := NewParallelExecutor(3, executors...)(ctx)
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
// Not all tasks should start due to cancellation (but timing may vary)
// Just verify cancellation occurred
t.Logf("Started %d tasks before cancellation", startedCount)
})
}
// TestMaxParallelResourceSharing tests resource sharing scenarios
func TestMaxParallelResourceSharing(t *testing.T) {
t.Run("SharedResourceWithMutex", func(t *testing.T) {
var sharedCounter int
var mu sync.Mutex
executors := make([]Executor, 100)
for i := range 100 {
executors[i] = func(ctx context.Context) error {
mu.Lock()
sharedCounter++
mu.Unlock()
return nil
}
}
ctx := context.Background()
err := NewParallelExecutor(10, executors...)(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, 100, sharedCounter, "All tasks should increment counter")
})
t.Run("ChannelCommunication", func(t *testing.T) {
resultChan := make(chan int, 50)
executors := make([]Executor, 50)
for i := range 50 {
taskID := i
executors[i] = func(ctx context.Context) error {
resultChan <- taskID
return nil
}
}
ctx := context.Background()
err := NewParallelExecutor(5, executors...)(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
close(resultChan)
results := make(map[int]bool)
for result := range resultChan {
results[result] = true
}
assert.Len(t, results, 50, "All task IDs should be received")
})
}
+101 -142
View File
@@ -7,8 +7,8 @@ package common
import ( import (
"context" "context"
"errors" "errors"
"reflect" "fmt"
"strings" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -18,166 +18,109 @@ import (
) )
func TestNewWorkflow(t *testing.T) { func TestNewWorkflow(t *testing.T) {
assert := assert.New(t) ctx := t.Context()
require.NoError(t, NewPipelineExecutor()(ctx))
require.EqualError(t, NewErrorExecutor(errors.New("test error"))(ctx), "test error")
ctx := context.Background()
// empty
emptyWorkflow := NewPipelineExecutor()
assert.NoError(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
// error case
errorWorkflow := NewErrorExecutor(errors.New("test error"))
assert.Error(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
// multiple success case
runcount := 0 runcount := 0
successWorkflow := NewPipelineExecutor( increment := func(_ctx context.Context) error {
func(ctx context.Context) error {
runcount++ runcount++
return nil return nil
}, }
func(ctx context.Context) error { require.NoError(t, NewPipelineExecutor(increment, increment)(ctx))
runcount++ assert.Equal(t, 2, runcount)
return nil
})
assert.NoError(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(2, runcount)
} }
func TestNewConditionalExecutor(t *testing.T) { func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic.Int32) {
assert := assert.New(t) var counted, active, peak atomic.Int32
var once sync.Once
reached := make(chan struct{})
ctx := context.Background() return func(_ctx context.Context) error {
counted.Add(1)
trueCount := 0 running := active.Add(1)
falseCount := 0
err := NewConditionalExecutor(func(ctx context.Context) bool {
return false
}, func(ctx context.Context) error {
trueCount++
return nil
}, func(ctx context.Context) error {
falseCount++
return nil
})(ctx)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(0, trueCount)
assert.Equal(1, falseCount)
err = NewConditionalExecutor(func(ctx context.Context) bool {
return true
}, func(ctx context.Context) error {
trueCount++
return nil
}, func(ctx context.Context) error {
falseCount++
return nil
})(ctx)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(1, trueCount)
assert.Equal(1, falseCount)
}
func TestNewParallelExecutor(t *testing.T) {
assert := assert.New(t)
ctx := context.Background()
var count, activeCount, maxCount atomic.Int32
emptyWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
count.Add(1)
active := activeCount.Add(1)
for { for {
m := maxCount.Load() seen := peak.Load()
if active <= m || maxCount.CompareAndSwap(m, active) { if running <= seen || peak.CompareAndSwap(seen, running) {
break break
} }
} }
time.Sleep(2 * time.Second) if running >= wantActive {
activeCount.Add(-1) once.Do(func() { close(reached) })
}
<-reached
active.Add(-1)
return nil return nil
}, &counted, &peak
}
func TestNewParallelExecutor(t *testing.T) {
for name, parallel := range map[string]int{"parallel": 2, "below minimum": 0} {
t.Run(name, func(t *testing.T) {
exec, count, maxActive := concurrencyProbe(int32(max(1, parallel)))
require.NoError(t, NewParallelExecutor(parallel, exec, exec, exec)(t.Context()))
assert.Equal(t, int32(3), count.Load())
assert.Equal(t, int32(max(1, parallel)), maxActive.Load())
}) })
}
err := NewParallelExecutor(2, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
// Reset to test running the executor with 0 parallelism
count.Store(0)
activeCount.Store(0)
maxCount.Store(0)
errSingle := NewParallelExecutor(0, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
assert.NoError(errSingle)
} }
func TestNewParallelExecutorEmpty(t *testing.T) { func TestNewParallelExecutorEmpty(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
ctx := context.Background() ctx := t.Context()
require.NoError(t, NewParallelExecutor(2)(ctx)) require.NoError(t, NewParallelExecutor(2)(ctx))
canceledCtx, cancel := context.WithCancel(context.Background()) canceledCtx, cancel := context.WithCancel(t.Context())
cancel() cancel()
err := NewParallelExecutor(2)(canceledCtx) err := NewParallelExecutor(2)(canceledCtx)
assert.ErrorIs(err, context.Canceled) assert.ErrorIs(err, context.Canceled)
} }
func TestNewParallelExecutorFailed(t *testing.T) { func TestNewParallelExecutorRunsAllTasksAfterFailureOrCancellation(t *testing.T) {
assert := assert.New(t) for name, tc := range map[string]struct {
canceled bool
ctx, cancel := context.WithCancel(context.Background()) parallel, tasks int
}{
"failure": {false, 2, 5},
"canceled": {true, 2, 5},
"canceled single task": {true, 1, 1},
} {
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
if tc.canceled {
cancel() cancel()
}
count := 0 var calls atomic.Int32
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error { executors := make([]Executor, tc.tasks)
count++ for i := range executors {
executors[i] = func(_ctx context.Context) error {
calls.Add(1)
if i == tc.tasks/2 {
return errors.New("fake error") return errors.New("fake error")
}) }
err := NewParallelExecutor(1, errorWorkflow)(ctx)
assert.Equal(1, count)
assert.ErrorIs(context.Canceled, err)
}
func TestNewParallelExecutorCanceled(t *testing.T) {
assert := assert.New(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
errExpected := errors.New("fake error")
var count atomic.Int32
successWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
count.Add(1)
return nil return nil
}
}
err := NewParallelExecutor(tc.parallel, executors...)(ctx)
if tc.canceled {
require.ErrorIs(t, err, context.Canceled)
} else {
require.EqualError(t, err, "fake error")
}
assert.Equal(t, int32(tc.tasks), calls.Load())
}) })
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error { }
count.Add(1)
return errExpected
})
err := NewParallelExecutor(3, errorWorkflow, successWorkflow, successWorkflow)(ctx)
assert.Equal(int32(3), count.Load())
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
} }
func TestExecutorConditionalsAndFinally(t *testing.T) { func TestExecutorConditionalsAndFinally(t *testing.T) {
ctx := context.Background() ctx := t.Context()
var calls []string var calls []string
record := func(name string) Executor { record := func(name string) Executor {
return func(ctx context.Context) error { return func(_ctx context.Context) error {
calls = append(calls, name) calls = append(calls, name)
return nil return nil
} }
@@ -189,26 +132,42 @@ func TestExecutorConditionalsAndFinally(t *testing.T) {
require.NoError(t, record("if-bool").IfBool(true)(ctx)) require.NoError(t, record("if-bool").IfBool(true)(ctx))
require.NoError(t, record("main").Finally(record("finally"))(ctx)) require.NoError(t, record("main").Finally(record("finally"))(ctx))
want := []string{"if-true", "if-not", "if-bool", "main", "finally"} assert.Equal(t, []string{"if-true", "if-not", "if-bool", "main", "finally"}, calls)
if !reflect.DeepEqual(calls, want) { }
t.Fatalf("calls = %v, want %v", calls, want)
func TestExecutorRetryStopsOnSuccessOrAfterAllAttempts(t *testing.T) {
backoff := RetryBackoff
RetryBackoff = func() time.Duration { return 0 }
t.Cleanup(func() { RetryBackoff = backoff })
transient := errors.New("transient")
for _, tc := range []struct {
failures, calls int
err error
}{
{failures: 2, calls: 3},
{failures: 5, calls: 3, err: transient},
} {
var calls int
err := Executor(func(context.Context) error {
calls++
if calls <= tc.failures {
return transient
}
return nil
}).Retry(3)(t.Context())
require.Equal(t, tc.err, err)
require.Equal(t, tc.calls, calls)
} }
} }
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) { func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
mainErr := errors.New("main failed")
finalErr := errors.New("cleanup failed") finalErr := errors.New("cleanup failed")
for _, mainErr := range []error{context.Canceled, nil} {
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(context.Background()) err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(t.Context())
require.Error(t, err) require.EqualError(t, err, fmt.Sprintf("error occurred running finally: cleanup failed (original error: %v)", mainErr))
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") { require.ErrorIs(t, err, finalErr)
t.Fatalf("finally error = %q, want both cleanup and original error", err) if mainErr != nil {
require.ErrorIs(t, err, mainErr)
} }
}
func TestConditionalNot(t *testing.T) {
cond := Conditional(func(context.Context) bool { return false })
if !cond.Not()(context.Background()) {
t.Fatal("inverted conditional should be true")
} }
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+372 -412
View File
File diff suppressed because it is too large. Load diff
+414 -333
View File
File diff suppressed because it is too large. Load diff
+2 -8
View File
@@ -14,13 +14,8 @@ const jobErrorContextKeyVal = jobErrorContextKey("job.error")
// JobError returns the job error for current context if any // JobError returns the job error for current context if any
func JobError(ctx context.Context) error { func JobError(ctx context.Context) error {
val := ctx.Value(jobErrorContextKeyVal) container, _ := ctx.Value(jobErrorContextKeyVal).(map[string]error)
if val != nil {
if container, ok := val.(map[string]error); ok {
return container["error"] return container["error"]
}
}
return nil
} }
func SetJobError(ctx context.Context, err error) { func SetJobError(ctx context.Context, err error) {
@@ -31,6 +26,5 @@ func SetJobError(ctx context.Context, err error) {
// WithJobErrorContainer adds a value to the context as a container for an error // WithJobErrorContainer adds a value to the context as a container for an error
func WithJobErrorContainer(ctx context.Context) context.Context { func WithJobErrorContainer(ctx context.Context) context.Context {
container := map[string]error{} return context.WithValue(ctx, jobErrorContextKeyVal, map[string]error{})
return context.WithValue(ctx, jobErrorContextKeyVal, container)
} }
+7 -19
View File
@@ -26,9 +26,7 @@ type lineWriter struct {
// NewLineWriter creates a new instance of a line writer // NewLineWriter creates a new instance of a line writer
func NewLineWriter(handlers ...LineHandler) io.Writer { func NewLineWriter(handlers ...LineHandler) io.Writer {
w := new(lineWriter) return &lineWriter{handlers: handlers}
w.handlers = handlers
return w
} }
// FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so // FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so
@@ -39,24 +37,15 @@ func FlushWriter(w io.Writer) {
} }
} }
func (lw *lineWriter) Write(p []byte) (n int, err error) { func (lw *lineWriter) Write(p []byte) (int, error) {
pBuf := bytes.NewBuffer(p) for line := range bytes.SplitAfterSeq(p, []byte{'\n'}) {
written := 0 _, _ = lw.buffer.Write(line)
for { if len(line) > 0 && line[len(line)-1] == '\n' {
line, err := pBuf.ReadString('\n')
w, _ := lw.buffer.WriteString(line)
written += w
if err == nil {
lw.handleLine(lw.buffer.String()) lw.handleLine(lw.buffer.String())
lw.buffer.Reset() lw.buffer.Reset()
} else if err == io.EOF {
break
} else {
return written, err
} }
} }
return len(p), nil
return written, nil
} }
// Flush emits any buffered, not-yet-newline-terminated content as a final line. // Flush emits any buffered, not-yet-newline-terminated content as a final line.
@@ -72,8 +61,7 @@ func (lw *lineWriter) Flush() {
func (lw *lineWriter) handleLine(line string) { func (lw *lineWriter) handleLine(line string) {
for _, h := range lw.handlers { for _, h := range lw.handlers {
ok := h(line) if !h(line) {
if !ok {
break break
} }
} }
+12 -34
View File
@@ -9,24 +9,24 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestLineWriter(t *testing.T) { func TestLineWriterBuffersAndFlushesLines(t *testing.T) {
lines := make([]string, 0) var lines []string
lineHandler := func(s string) bool { lineWriter := NewLineWriter(func(s string) bool {
lines = append(lines, s) lines = append(lines, s)
return true return true
} })
lineWriter := NewLineWriter(lineHandler)
assert := assert.New(t) assert := assert.New(t)
write := func(s string) { write := func(s string) {
n, err := lineWriter.Write([]byte(s)) n, err := lineWriter.Write([]byte(s))
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
assert.Equal(len(s), n, s) assert.Equal(len(s), n, s)
} }
write("")
write("hello") write("hello")
write(" ") write(" ")
write("world!!\nextra") write("world!!\nextra")
@@ -34,39 +34,17 @@ func TestLineWriter(t *testing.T) {
write(" line\n") write(" line\n")
write("no newline here...") write("no newline here...")
assert.Len(lines, 4) expected := []string{"hello world!!\n", "extra line\n", " and another\n", "last line\n"}
assert.Equal("hello world!!\n", lines[0]) assert.Equal(expected, lines)
assert.Equal("extra line\n", lines[1])
assert.Equal(" and another\n", lines[2])
assert.Equal("last line\n", lines[3])
}
func TestLineWriterFlush(t *testing.T) {
lines := make([]string, 0)
lineHandler := func(s string) bool {
lines = append(lines, s)
return true
}
lineWriter := NewLineWriter(lineHandler)
assert := assert.New(t)
_, err := lineWriter.Write([]byte("complete line\npartial line without newline"))
assert.NoError(err) //nolint:testifylint // pre-existing pattern from nektos/act
// Only the newline-terminated line is emitted before flushing.
assert.Equal([]string{"complete line\n"}, lines)
// Flushing emits the buffered, not-yet-terminated trailing line.
FlushWriter(lineWriter) FlushWriter(lineWriter)
assert.Equal([]string{"complete line\n", "partial line without newline"}, lines) expected = append(expected, "no newline here...")
assert.Equal(expected, lines)
// Flushing again is a no-op: nothing is buffered.
FlushWriter(lineWriter) FlushWriter(lineWriter)
assert.Len(lines, 2) assert.Equal(expected, lines)
} }
func TestFlushWriterIgnoresNonFlusher(t *testing.T) { func TestFlushWriterIgnoresNonFlusher(t *testing.T) {
// FlushWriter must be a safe no-op for writers that do not buffer lines.
assert.NotPanics(t, func() { FlushWriter(io.Discard) }) assert.NotPanics(t, func() { FlushWriter(io.Discard) })
} }
+29 -24
View File
@@ -7,46 +7,51 @@ package common
import ( import (
"context" "context"
"github.com/sirupsen/logrus" "gitea.com/gitea/runner/act/common/logging"
) )
type loggerContextKey string type loggerContextKey string
const loggerContextKeyVal = loggerContextKey("logrus.FieldLogger") const loggerContextKeyVal = loggerContextKey("logging.Logger")
// Logger returns the appropriate logger for current context // Logger returns the appropriate logger for current context
func Logger(ctx context.Context) logrus.FieldLogger { func Logger(ctx context.Context) *logging.Logger {
val := ctx.Value(loggerContextKeyVal) if logger, ok := ctx.Value(loggerContextKeyVal).(*logging.Logger); ok {
if val != nil {
if logger, ok := val.(logrus.FieldLogger); ok {
return logger return logger
} }
} return logging.Default()
return logrus.StandardLogger()
} }
// WithLogger adds a value to the context for the logger // WithLogger adds a value to the context for the logger
func WithLogger(ctx context.Context, logger logrus.FieldLogger) context.Context { func WithLogger(ctx context.Context, logger *logging.Logger) context.Context {
return context.WithValue(ctx, loggerContextKeyVal, logger) return context.WithValue(ctx, loggerContextKeyVal, logger)
} }
type loggerHookKey string type loggerHandlerKey string
const loggerHookKeyVal = loggerHookKey("logrus.Hook") const loggerHandlerKeyVal = loggerHandlerKey("logging.Handler")
// LoggerHook returns the appropriate logger hook for current context // LoggerHandler returns the extra handler the job logger sends its records to, or nil.
// the hook affects job logger, not global logger // It affects the job logger, not the global logger.
func LoggerHook(ctx context.Context) logrus.Hook { func LoggerHandler(ctx context.Context) logging.Handler {
val := ctx.Value(loggerHookKeyVal) h, _ := ctx.Value(loggerHandlerKeyVal).(logging.Handler)
if val != nil { return h
if hook, ok := val.(logrus.Hook); ok {
return hook
}
}
return nil
} }
// WithLoggerHook adds a value to the context for the logger hook // WithLoggerHandler adds the extra handler for the job logger to the context
func WithLoggerHook(ctx context.Context, hook logrus.Hook) context.Context { func WithLoggerHandler(ctx context.Context, h logging.Handler) context.Context {
return context.WithValue(ctx, loggerHookKeyVal, hook) return context.WithValue(ctx, loggerHandlerKeyVal, h)
}
type stepIDsContextKey struct{}
// StepIDs returns the IDs of the step and the composite steps enclosing the current one, outermost first.
func StepIDs(ctx context.Context) []string {
ids, _ := ctx.Value(stepIDsContextKey{}).([]string)
return ids
}
// WithStepIDs replaces the step IDs in the context
func WithStepIDs(ctx context.Context, ids []string) context.Context {
return context.WithValue(ctx, stepIDsContextKey{}, ids)
} }
+284
View File
@@ -0,0 +1,284 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package logging
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"maps"
"reflect"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
)
const (
ansiReset = "\x1b[0m"
ansiRed = "\x1b[31m"
ansiYellow = "\x1b[33m"
ansiCyan = "\x1b[36m"
ansiDimCyan = "\x1b[2;36m"
ansiDimWhite = "\x1b[2;37m"
)
// consoleStart is the zero of the elapsed-seconds stamp used when full timestamps are off.
var consoleStart = time.Now()
// console is the state shared by every consoleHandler derived from one another.
type console struct {
mu sync.Mutex
out io.Writer
terminal bool
opts TextOptions
reportCaller bool
level slog.LevelVar
}
func newConsole(out io.Writer) *console {
c := &console{}
c.setOutput(out)
return c
}
func (c *console) setOutput(w io.Writer) {
terminal := checkIfTerminal(w)
c.mu.Lock()
defer c.mu.Unlock()
c.out, c.terminal = w, terminal
}
// consoleHandler is a slog.Handler writing the text format the runner has always used, so that
// moving off logrus changes nothing a user can see: sorted key=value fields on a plain stream,
// and a colored prefix with elapsed or full time on a terminal.
type consoleHandler struct {
c *console
attrs []slog.Attr
}
func (h *consoleHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.c.level.Level()
}
func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &consoleHandler{c: h.c, attrs: append(slices.Clone(h.attrs), attrs...)}
}
// WithGroup is a no-op, as only NewSlogHandler wraps a consoleHandler and it never groups.
func (h *consoleHandler) WithGroup(string) slog.Handler { return h }
func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
c := h.c
c.mu.Lock()
opts, reportCaller, terminal := c.opts, c.reportCaller, c.terminal
c.mu.Unlock()
data := make(map[string]any, len(h.attrs)+r.NumAttrs())
for _, a := range h.attrs {
data[a.Key] = a.Value.Resolve().Any()
}
r.Attrs(func(a slog.Attr) bool {
data[a.Key] = a.Value.Resolve().Any()
return true
})
var caller *runtime.Frame
if reportCaller && r.PC != 0 {
frame, _ := runtime.CallersFrames([]uintptr{r.PC}).Next()
caller = &frame
}
prefixFieldClashes(data, caller != nil)
keys := slices.Sorted(maps.Keys(data))
var b bytes.Buffer
if terminal && !opts.DisableColors {
printColored(&b, opts, r, caller, keys, data)
} else {
printPlain(&b, opts, r, caller, keys, data)
}
line := b.Bytes()
if opts.Filter != nil {
line = opts.Filter(line)
}
c.mu.Lock()
defer c.mu.Unlock()
_, err := c.out.Write(line)
return err
}
// prefixFieldClashes moves fields that share a name with a fixed key out of its way.
func prefixFieldClashes(data map[string]any, reportCaller bool) {
for _, key := range []string{"time", "level", "msg"} {
if v, ok := data[key]; ok {
data["fields."+key] = v
delete(data, key)
}
}
if reportCaller {
for _, key := range []string{"func", "file"} {
if v, ok := data[key]; ok {
data["fields."+key] = v
}
}
}
}
func printPlain(b *bytes.Buffer, opts TextOptions, r slog.Record, caller *runtime.Frame, keys []string, data map[string]any) {
if !opts.DisableTimestamp {
appendKeyValue(b, "time", r.Time.Format(time.RFC3339))
}
appendKeyValue(b, "level", plainLevel(Level(r.Level)))
if r.Message != "" {
appendKeyValue(b, "msg", r.Message)
}
if caller != nil {
funcVal, fileVal := callerText(opts, caller)
if funcVal != "" {
appendKeyValue(b, "func", funcVal)
}
if fileVal != "" {
appendKeyValue(b, "file", fileVal)
}
}
for _, k := range keys {
appendKeyValue(b, k, data[k])
}
b.WriteByte('\n')
}
func callerText(opts TextOptions, caller *runtime.Frame) (funcVal, fileVal string) {
if opts.CallerPrettyfier != nil {
return opts.CallerPrettyfier(caller)
}
return caller.Function, caller.File + ":" + strconv.Itoa(caller.Line)
}
func printColored(b *bytes.Buffer, opts TextOptions, r slog.Record, caller *runtime.Frame, keys []string, data map[string]any) {
msg := strings.TrimSuffix(r.Message, "\n")
var callerStr string
if caller != nil {
funcVal, fileVal := callerText(opts, caller)
if opts.CallerPrettyfier == nil && caller.Function != "" {
funcVal += "()"
}
switch {
case fileVal == "":
callerStr = funcVal
case funcVal == "":
callerStr = fileVal
default:
callerStr = fileVal + " " + funcVal
}
}
level := Level(r.Level)
prefix := colorize(level, strings.ToUpper(plainLevel(level))[:4])
switch {
case opts.DisableTimestamp:
fmt.Fprintf(b, "%s%s %-44s ", prefix, callerStr, msg)
case !opts.FullTimestamp:
fmt.Fprintf(b, "%s[%04d]%s %-44s ", prefix, int(r.Time.Sub(consoleStart)/time.Second), callerStr, msg)
default:
fmt.Fprintf(b, "%s[%s]%s %-44s ", prefix, r.Time.Format(time.RFC3339), callerStr, msg)
}
for _, k := range keys {
b.WriteByte(' ')
b.WriteString(colorize(level, k))
b.WriteByte('=')
appendValue(b, data[k])
}
b.WriteByte('\n')
}
// plainLevel spells Warn the way logrus did, as "warning".
func plainLevel(l Level) string {
if s := l.String(); s != "warn" {
return s
}
return "warning"
}
func colorize(l Level, s string) string {
var color string
switch {
case l <= LevelTrace:
color = ansiDimWhite
case l <= LevelDebug:
color = ansiDimCyan
case l <= LevelInfo:
color = ansiCyan
case l <= LevelWarn:
color = ansiYellow
default:
color = ansiRed
}
return color + s + ansiReset
}
func appendKeyValue(b *bytes.Buffer, key string, value any) {
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(key)
b.WriteByte('=')
appendValue(b, value)
}
func appendValue(b *bytes.Buffer, value any) {
switch v := value.(type) {
case string:
appendString(b, v)
case []byte:
appendString(b, string(v))
case error:
appendFormatted(b, v, v.Error)
case fmt.Stringer:
appendFormatted(b, v, v.String)
default:
appendString(b, fmt.Sprint(value))
}
}
// appendFormatted writes what get returns, and "<nil>" if that panics on a nil receiver.
func appendFormatted(b *bytes.Buffer, v any, get func() string) {
defer func() {
if r := recover(); r != nil {
if rv := reflect.ValueOf(v); rv.Kind() == reflect.Pointer && rv.IsNil() {
appendString(b, "<nil>")
} else {
appendString(b, fmt.Sprintf("%%!v(PANIC=%v)", r))
}
}
}()
appendString(b, get())
}
func appendString(b *bytes.Buffer, s string) {
if needsQuoting(s) {
b.WriteString(strconv.Quote(s))
return
}
b.WriteString(s)
}
func needsQuoting(s string) bool {
for i := range len(s) {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
case c == '-', c == '.', c == '_', c == '/', c == '@', c == '^', c == '+':
default:
return true
}
}
return false
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package logging
import (
"bytes"
"errors"
"log/slog"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func consoleLine(t *testing.T, opts TextOptions, terminal, reportCaller bool, build func(*Logger)) string {
t.Helper()
var out bytes.Buffer
c := newConsole(&out)
c.opts, c.terminal, c.reportCaller = opts, terminal, reportCaller
c.level.Set(slog.LevelDebug)
build(New(NewSlogHandler(&consoleHandler{c: c})))
return out.String()
}
func TestConsolePlain(t *testing.T) {
line := consoleLine(t, TextOptions{DisableTimestamp: true}, false, false, func(l *Logger) {
l.With("job", "build x", "n", 3).WithError(errors.New("boom")).Warnf("hello world")
l.With("msg", "clash", "empty", "").Info("")
})
assert.Equal(t, "level=warning msg=\"hello world\" error=boom job=\"build x\" n=3\n"+
"level=info empty= fields.msg=clash\n", line)
}
func TestConsolePlainTimeFilterAndCaller(t *testing.T) {
opts := TextOptions{
CallerPrettyfier: func(f *runtime.Frame) (string, string) {
return "[fn]", "[" + strings.TrimPrefix(f.Function, "x") + "]"
},
Filter: func(b []byte) []byte { return bytes.ReplaceAll(b, []byte("secret"), []byte("***")) },
}
line := consoleLine(t, opts, false, true, func(l *Logger) { l.With("k", "secret").Debugf("a secret") })
assert.Regexp(t, `^time="[^"]+" level=debug msg="a \*\*\*" func="\[fn\]" file="\[.*TestConsolePlainTimeFilterAndCaller[^"]*\]" k=\*\*\*\n$`, line)
}
func TestConsoleColored(t *testing.T) {
line := consoleLine(t, TextOptions{DisableTimestamp: true}, true, false, func(l *Logger) {
l.With("job", "j").Warn("careful")
})
assert.Equal(t, "\x1b[33mWARN\x1b[0m "+"careful"+strings.Repeat(" ", 44-len("careful"))+" \x1b[33mjob\x1b[0m=j\n", line)
full := consoleLine(t, TextOptions{FullTimestamp: true}, true, false, func(l *Logger) { l.Error("bad") })
assert.Regexp(t, `^\x1b\[31mERRO\x1b\[0m\[\d{4}-\d\d-\d\dT[^\]]+\] bad`, full)
elapsed := consoleLine(t, TextOptions{}, true, false, func(l *Logger) { l.Info("x") })
assert.Regexp(t, `^\x1b\[36mINFO\x1b\[0m\[\d{4}\] x`, elapsed)
plain := consoleLine(t, TextOptions{DisableColors: true, DisableTimestamp: true}, true, false, func(l *Logger) { l.Info("x") })
assert.Equal(t, "level=info msg=x\n", plain)
}
func TestConsoleLevelAndOutput(t *testing.T) {
var out bytes.Buffer
c := newConsole(&out)
h := &consoleHandler{c: c}
assert.False(t, h.Enabled(t.Context(), slog.LevelDebug)) // info unless raised
c.level.Set(slog.LevelError)
assert.False(t, h.Enabled(t.Context(), slog.LevelWarn))
assert.True(t, h.Enabled(t.Context(), slog.LevelError))
rec := slog.NewRecord(time.Unix(0, 0), slog.LevelError, "m", 0)
rec.AddAttrs(slog.String("c", "3"))
require.NoError(t, h.WithAttrs([]slog.Attr{slog.Int("a", 1)}).Handle(t.Context(), rec))
assert.Contains(t, out.String(), "a=1 c=3")
}
Loaded 50 of 418 files, more files were not shown because too many files have changed in this diff. Show more