Compare commits

...

39 Commits

Author SHA1 Message Date
Renovate Bot
4ee3417fa6 chore(deps): pin dependencies 2026-07-27 00:08:17 +00:00
Renovate Bot
8c519ce318 fix(deps): update module github.com/prometheus/client_golang to v1.24.0 (#1113)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) | `v1.23.2` → `v1.24.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fprometheus%2fclient_golang/v1.24.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fprometheus%2fclient_golang/v1.23.2/v1.24.0?slim=true) |

---

### Release Notes

<details>
<summary>prometheus/client_golang (github.com/prometheus/client_golang)</summary>

### [`v1.24.0`](https://github.com/prometheus/client_golang/releases/tag/v1.24.0): - 2026-07-20

[Compare Source](https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.0)

##### Changes

- \[CHANGE] Minimum required Go version is now 1.25, only the two latest Go versions (1.25 and 1.26) are supported from now on. [#&#8203;1862](https://github.com/prometheus/client_golang/issues/1862)
- \[CHANGE] prometheus: Name validation now always uses the UTF-8 scheme instead of the deprecated `model.NameValidationScheme` global. Default behavior is unchanged; code that set `NameValidationScheme = LegacyValidation` no longer gets legacy enforcement at metric, label, and push-grouping construction. [#&#8203;2051](https://github.com/prometheus/client_golang/issues/2051)
- \[CHANGE] api/prometheus/v1: Support matchers (`matches[]` parameter) in `Rules` method (`Rules(ctx context.Context, matches []string) (RulesResult, error)`). [#&#8203;1843](https://github.com/prometheus/client_golang/issues/1843)
- \[CHANGE] api/prometheus/v1: Refactor `LabelNames` method to return `model.LabelNames` instead of `[]string` for consistency across the API. [#&#8203;1850](https://github.com/prometheus/client_golang/issues/1850)
- \[CHANGE] exp/api/remote: Simplify `Store` interface, rename `Handler` to `WriteHandler`, and encapsulate write response handling. [#&#8203;1855](https://github.com/prometheus/client_golang/issues/1855)
- \[FEATURE] prometheus: Add new Go 1.26 runtime metrics (`/sched/goroutines-created:goroutines`, `/sched/goroutines/not-in-go:goroutines`, `/sched/goroutines/runnable:goroutines`, `/sched/goroutines/running:goroutines`, `/sched/goroutines/waiting:goroutines`, `/sched/threads/total:threads`). [#&#8203;1942](https://github.com/prometheus/client_golang/issues/1942)
- \[FEATURE] prometheus: Add `WithUnit(unit string)` option and explicit OpenMetrics unit support in `CounterOpts`, `GaugeOpts`, `SummaryOpts`, and `HistogramOpts`. [#&#8203;1392](https://github.com/prometheus/client_golang/issues/1392)
- \[FEATURE] prometheus: Expose descriptor construction error through public `Err()` method on `Desc`. [#&#8203;1902](https://github.com/prometheus/client_golang/issues/1902)
- \[FEATURE] promhttp: Add opt-in `HandlerOpts.CoalesceGather` to deduplicate concurrent `Gather` calls so overlapping scrapes share one collection cycle, preventing goroutine pile-up when the scrape rate outpaces collection time. [#&#8203;1969](https://github.com/prometheus/client_golang/issues/1969)
- \[FEATURE] promhttp: HTTP handlers created by `promhttp` package now support metrics filtering by providing one or more `name[]` query parameters. The default behavior when none are provided remains the same, returning all metrics. [#&#8203;1925](https://github.com/prometheus/client_golang/issues/1925)
- \[FEATURE] api/prometheus/v1: Add query formatting endpoint support (`/format_query`) and `FormatQuery(ctx context.Context, query string) (string, error)` method. [#&#8203;1846](https://github.com/prometheus/client_golang/issues/1846), [#&#8203;1856](https://github.com/prometheus/client_golang/issues/1856)
- \[FEATURE] api/prometheus/v1: Add support for `/status/tsdb/blocks` endpoint via `TSDBBlocks(ctx context.Context) ([]TSDBBlock, error)` method. [#&#8203;1896](https://github.com/prometheus/client_golang/issues/1896)
- \[FEATURE] exp/api/remote: Export `BackoffConfig` to allow customization when using `WithAPIBackoff`. [#&#8203;1895](https://github.com/prometheus/client_golang/issues/1895)
- \[FEATURE] exp/api/remote: Add `RetryCallBack` to allow custom logging or handling on retry attempts in the remote write client. [#&#8203;1888](https://github.com/prometheus/client_golang/issues/1888), [#&#8203;1890](https://github.com/prometheus/client_golang/issues/1890)
- \[ENHANCEMENT] prometheus/collectors/version: Allow specifying custom labels when registering the version collector. [#&#8203;1860](https://github.com/prometheus/client_golang/issues/1860)
- \[ENHANCEMENT] api: Use cloned `http.DefaultTransport` when constructing default HTTP clients to prevent accidental mutations of shared global transport state. [#&#8203;1885](https://github.com/prometheus/client_golang/issues/1885)
- \[BUGFIX] prometheus: Recover from collector panics during `Gather()` and return an error instead of crashing the process. [#&#8203;1961](https://github.com/prometheus/client_golang/issues/1961)
- \[BUGFIX] prometheus: Fix `cpu-seconds` unit suffix handling for metric `go_cpu_classes_gc_mark_assist_cpu_seconds`. [#&#8203;1991](https://github.com/prometheus/client_golang/issues/1991)
- \[BUGFIX] promhttp: `InstrumentHandlerDuration` and `InstrumentHandlerCounter` no longer panic when given an observer/counter that does not implement `ExemplarObserver`/`ExemplarAdder` (e.g. a `SummaryVec`). The exemplar is dropped and the value is recorded via the plain `Observe`/`Add` path, matching the safe-cast already used by `Timer.ObserveDurationWithExemplar`. [#&#8203;2005](https://github.com/prometheus/client_golang/issues/2005)
- \[BUGFIX] api/prometheus/v1: Fall back to `GET` requests when `POST` requests return `403 Forbidden` or method not allowed. [#&#8203;2030](https://github.com/prometheus/client_golang/issues/2030)
- \[BUGFIX] api: Respect context cancellation inside `httpClient.Do`. [#&#8203;1971](https://github.com/prometheus/client_golang/issues/1971)
- \[BUGFIX] exp/api/remote: Fix compression buffer pooling where compressed buffers were released prematurely, causing corrupted remote-write payloads. [#&#8203;1889](https://github.com/prometheus/client_golang/issues/1889)
- \[BUGFIX] exp/api/remote: Reject malformed snappy payloads declaring huge decoded sizes. Enforce a 32MB decoded-size limit to prevent OOM from oversized remote-write requests. [#&#8203;1917](https://github.com/prometheus/client_golang/issues/1917)
- \[BUGFIX] exp/api/remote: Ensure remote write v2 headers cannot be returned on v1 requests. [#&#8203;1927](https://github.com/prometheus/client_golang/issues/1927)

<details>
<summary> All commits </summary>

- build(deps): bump github.com/prometheus/procfs from 0.16.1 to 0.17.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1839](https://github.com/prometheus/client_golang/pull/1839)
- build(deps): bump golang.org/x/sys from 0.33.0 to 0.34.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1838](https://github.com/prometheus/client_golang/pull/1838)
- prometheus/collectors: use godoc link for runtime/metrics supported metrics by [@&#8203;xieyuschen](https://github.com/xieyuschen) in [#&#8203;1844](https://github.com/prometheus/client_golang/pull/1844)
- Fix doc typo by [@&#8203;torrca](https://github.com/torrca) in [#&#8203;1849](https://github.com/prometheus/client_golang/pull/1849)
- Merge release-1.23 into main by [@&#8203;vesari](https://github.com/vesari) in [#&#8203;1851](https://github.com/prometheus/client_golang/pull/1851)
- build(deps): bump github/codeql-action from 3.29.2 to 3.29.5 in the github-actions group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1852](https://github.com/prometheus/client_golang/pull/1852)
- Refactor LabelNames to return model.LabelNames type for consistency by [@&#8203;yshngg](https://github.com/yshngg) in [#&#8203;1850](https://github.com/prometheus/client_golang/pull/1850)
- remote: simplified Store interface; renamed Handler to WriteHandler by [@&#8203;bwplotka](https://github.com/bwplotka) in [#&#8203;1855](https://github.com/prometheus/client_golang/pull/1855)
- feat(api/prometheus): add format\_query endpoint for query formatting by [@&#8203;yshngg](https://github.com/yshngg) in [#&#8203;1846](https://github.com/prometheus/client_golang/pull/1846)
- feat(api): add FormatQuery method to Prometheus v1 API by [@&#8203;yshngg](https://github.com/yshngg) in [#&#8203;1856](https://github.com/prometheus/client_golang/pull/1856)
- Support matchers in rules API by [@&#8203;jotak](https://github.com/jotak) in [#&#8203;1843](https://github.com/prometheus/client_golang/pull/1843)
- Use prometheus/common.expfmt.NewTextParser by [@&#8203;aknuds1](https://github.com/aknuds1) in [#&#8203;1859](https://github.com/prometheus/client_golang/pull/1859)
- Merge release-1.23 into main by [@&#8203;aknuds1](https://github.com/aknuds1) in [#&#8203;1861](https://github.com/prometheus/client_golang/pull/1861)
- chore: Drop support for \<go1.22 by [@&#8203;mrueg](https://github.com/mrueg) in [#&#8203;1862](https://github.com/prometheus/client_golang/pull/1862)
- collectors/version: Allow custom additional labels by [@&#8203;mrueg](https://github.com/mrueg) in [#&#8203;1860](https://github.com/prometheus/client_golang/pull/1860)
- build(deps): bump github.com/prometheus/common from 0.65.0 to 0.66.0 by [@&#8203;ywwg](https://github.com/ywwg) in [#&#8203;1865](https://github.com/prometheus/client_golang/pull/1865)
- Sync release-1.23 into main by [@&#8203;aknuds1](https://github.com/aknuds1) in [#&#8203;1868](https://github.com/prometheus/client_golang/pull/1868)
- Sync main with release-1.23 by [@&#8203;aknuds1](https://github.com/aknuds1) in [#&#8203;1871](https://github.com/prometheus/client_golang/pull/1871)
- chore: clean up golangci-lint configuration by [@&#8203;mmorel-35](https://github.com/mmorel-35) in [#&#8203;1802](https://github.com/prometheus/client_golang/pull/1802)
- build(deps): bump google.golang.org/protobuf from 1.36.8 to 1.36.9 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1880](https://github.com/prometheus/client_golang/pull/1880)
- build(deps): bump google.golang.org/protobuf from 1.36.6 to 1.36.9 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1882](https://github.com/prometheus/client_golang/pull/1882)
- build(deps): bump github.com/prometheus/common from 0.65.0 to 0.66.1 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1883](https://github.com/prometheus/client_golang/pull/1883)
- build(deps): bump the github-actions group with 4 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1881](https://github.com/prometheus/client_golang/pull/1881)
- Fix typo in remote api err msg by [@&#8203;SungJin1212](https://github.com/SungJin1212) in [#&#8203;1878](https://github.com/prometheus/client_golang/pull/1878)
- chore: Update metrics for new Go version by [@&#8203;github-actions](https://github.com/github-actions)\[bot] in [#&#8203;1864](https://github.com/prometheus/client_golang/pull/1864)
- Add RetryCallBack to remote\_api.go  by [@&#8203;pipiland2612](https://github.com/pipiland2612) in [#&#8203;1888](https://github.com/prometheus/client_golang/pull/1888)
- bug(remote\_write): Fix compression buffer pooling by [@&#8203;fpetkovski](https://github.com/fpetkovski) in [#&#8203;1889](https://github.com/prometheus/client_golang/pull/1889)
- Change RetryCallBack initialized by [@&#8203;pipiland2612](https://github.com/pipiland2612) in [#&#8203;1890](https://github.com/prometheus/client_golang/pull/1890)
- Fix CI bug by [@&#8203;pipiland2612](https://github.com/pipiland2612) in [#&#8203;1892](https://github.com/prometheus/client_golang/pull/1892)
- Use cloned http.DefaultTransport. issue-1857 by [@&#8203;karthikkondapally](https://github.com/karthikkondapally) in [#&#8203;1885](https://github.com/prometheus/client_golang/pull/1885)
- Public backoff config to allow usage of WithAPIBackoff by [@&#8203;pipiland2612](https://github.com/pipiland2612) in [#&#8203;1895](https://github.com/prometheus/client_golang/pull/1895)
- Clarify exp library stability by [@&#8203;pipiland2612](https://github.com/pipiland2612) in [#&#8203;1894](https://github.com/prometheus/client_golang/pull/1894)
- feat: add support for `/status/tsdb/blocks` endpoint by [@&#8203;tjhop](https://github.com/tjhop) in [#&#8203;1896](https://github.com/prometheus/client_golang/pull/1896)
- minor refactor of replaceInvalidRune() in bridge.go by [@&#8203;karthikkondapally](https://github.com/karthikkondapally) in [#&#8203;1897](https://github.com/prometheus/client_golang/pull/1897)
- build(deps): bump github.com/prometheus/procfs from 0.17.0 to 0.19.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1903](https://github.com/prometheus/client_golang/pull/1903)
- build(deps): bump github.com/klauspost/compress from 1.18.0 to 1.18.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1906](https://github.com/prometheus/client_golang/pull/1906)
- build(deps): bump golang.org/x/sys from 0.35.0 to 0.37.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1904](https://github.com/prometheus/client_golang/pull/1904)
- build(deps): bump github.com/prometheus/common from 0.66.1 to 0.67.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1907](https://github.com/prometheus/client_golang/pull/1907)
- build(deps): bump github.com/klauspost/compress from 1.18.0 to 1.18.1 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1911](https://github.com/prometheus/client_golang/pull/1911)
- build(deps): bump google.golang.org/protobuf from 1.36.9 to 1.36.10 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1909](https://github.com/prometheus/client_golang/pull/1909)
- build(deps): bump github.com/prometheus/common from 0.66.1 to 0.67.2 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1910](https://github.com/prometheus/client_golang/pull/1910)
- build(deps): bump the github-actions group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1908](https://github.com/prometheus/client_golang/pull/1908)
- chore(ci): Add CRLF detection and fix targets to prevent CRLF contamination by [@&#8203;kakkoyun](https://github.com/kakkoyun) in [#&#8203;1898](https://github.com/prometheus/client_golang/pull/1898)
- chore(ci): Use stable names for CI steps by [@&#8203;kakkoyun](https://github.com/kakkoyun) in [#&#8203;1914](https://github.com/prometheus/client_golang/pull/1914)
- build(deps): bump github.com/klauspost/compress from 1.18.1 to 1.18.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1920](https://github.com/prometheus/client_golang/pull/1920)
- build(deps): bump github.com/prometheus/common from 0.67.2 to 0.67.4 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1921](https://github.com/prometheus/client_golang/pull/1921)
- build(deps): bump golang.org/x/sys from 0.37.0 to 0.38.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1922](https://github.com/prometheus/client_golang/pull/1922)
- build(deps): bump github.com/prometheus/common from 0.67.2 to 0.67.4 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1923](https://github.com/prometheus/client_golang/pull/1923)
- build(deps): bump github.com/klauspost/compress from 1.18.1 to 1.18.2 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1924](https://github.com/prometheus/client_golang/pull/1924)
- build(deps): bump the github-actions group with 4 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1919](https://github.com/prometheus/client_golang/pull/1919)
- feat: expose Desc error through public Err() method by [@&#8203;duricanikolic](https://github.com/duricanikolic) in [#&#8203;1902](https://github.com/prometheus/client_golang/pull/1902)
- Allow `/metrics` handler output filtering via `name[]` query param by [@&#8203;colega](https://github.com/colega) in [#&#8203;1925](https://github.com/prometheus/client_golang/pull/1925)
- Prevent OOM from malformed snappy payloads by validating decoded length by [@&#8203;makasim](https://github.com/makasim) in [#&#8203;1917](https://github.com/prometheus/client_golang/pull/1917)
- Ensure remote write v2 headers cannot be returned on v1 requests by [@&#8203;kgeckhart](https://github.com/kgeckhart) in [#&#8203;1927](https://github.com/prometheus/client_golang/pull/1927)
- build(deps): bump google.golang.org/protobuf from 1.36.10 to 1.36.11 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1932](https://github.com/prometheus/client_golang/pull/1932)
- build(deps): bump golang.org/x/sys from 0.38.0 to 0.39.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1933](https://github.com/prometheus/client_golang/pull/1933)
- build(deps): bump google.golang.org/protobuf from 1.36.10 to 1.36.11 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1935](https://github.com/prometheus/client_golang/pull/1935)
- build(deps): bump the github-actions group with 5 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1934](https://github.com/prometheus/client_golang/pull/1934)
- promhttp/zstd: add unit tests for zstd writer registration by [@&#8203;90ashish](https://github.com/90ashish) in [#&#8203;1929](https://github.com/prometheus/client_golang/pull/1929)
- feat(collector): add Go 1.26 new runtime metrics by [@&#8203;kakkoyun](https://github.com/kakkoyun) in [#&#8203;1942](https://github.com/prometheus/client_golang/pull/1942)
- build(deps): bump github.com/prometheus/common from 0.67.4 to 0.67.5 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1948](https://github.com/prometheus/client_golang/pull/1948)
- build(deps): bump the github-actions group with 4 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1946](https://github.com/prometheus/client_golang/pull/1946)
- build(deps): bump github.com/klauspost/compress from 1.18.2 to 1.18.3 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1944](https://github.com/prometheus/client_golang/pull/1944)
- build(deps): bump golang.org/x/sys from 0.39.0 to 0.40.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1945](https://github.com/prometheus/client_golang/pull/1945)
- build(deps): bump github.com/klauspost/compress from 1.18.2 to 1.18.3 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1947](https://github.com/prometheus/client_golang/pull/1947)
- chore(test): bump 1.25, tests with synctest and check not panic by [@&#8203;manute](https://github.com/manute) in [#&#8203;1950](https://github.com/prometheus/client_golang/pull/1950)
- build(deps): bump github.com/prometheus/procfs from 0.19.2 to 0.20.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1954](https://github.com/prometheus/client_golang/pull/1954)
- build(deps): bump golang.org/x/sys from 0.40.0 to 0.41.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1957](https://github.com/prometheus/client_golang/pull/1957)
- build(deps): bump github.com/klauspost/compress from 1.18.3 to 1.18.4 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1955](https://github.com/prometheus/client_golang/pull/1955)
- build(deps): bump go.opentelemetry.io/otel/sdk from 1.34.0 to 1.40.0 in /tutorials/whatsup by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1959](https://github.com/prometheus/client_golang/pull/1959)
- build(deps): bump github.com/prometheus/common from 0.67.4 to 0.67.5 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1956](https://github.com/prometheus/client_golang/pull/1956)
- build(deps): bump the github-actions group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1958](https://github.com/prometheus/client_golang/pull/1958)
- chore(collectors/go): generate the tests after new metric by [@&#8203;kakkoyun](https://github.com/kakkoyun) in [#&#8203;1962](https://github.com/prometheus/client_golang/pull/1962)
- Remove Arthur from the list of maintainers by [@&#8203;ArthurSens](https://github.com/ArthurSens) in [#&#8203;1964](https://github.com/prometheus/client_golang/pull/1964)
- build(deps): bump google.golang.org/grpc from 1.69.4 to 1.79.3 in /tutorials/whatsup by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1965](https://github.com/prometheus/client_golang/pull/1965)
- fix: recover from collector panic and return error in Gather by [@&#8203;Saflaski](https://github.com/Saflaski) in [#&#8203;1961](https://github.com/prometheus/client_golang/pull/1961)
- prometheus: clarify MetricVec delete semantics in godoc by [@&#8203;Retr0-XD](https://github.com/Retr0-XD) in [#&#8203;1967](https://github.com/prometheus/client_golang/pull/1967)
- build(deps): bump golang.org/x/sys from 0.41.0 to 0.42.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1973](https://github.com/prometheus/client_golang/pull/1973)
- build(deps): bump github.com/klauspost/compress from 1.18.4 to 1.18.5 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1974](https://github.com/prometheus/client_golang/pull/1974)
- build(deps): bump github.com/klauspost/compress from 1.18.4 to 1.18.5 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1976](https://github.com/prometheus/client_golang/pull/1976)
- Optionally add OM unit  by [@&#8203;vesari](https://github.com/vesari) in [#&#8203;1392](https://github.com/prometheus/client_golang/pull/1392)
- fix: respect context cancellation in httpClient.Do by [@&#8203;pedrampdd](https://github.com/pedrampdd) in [#&#8203;1971](https://github.com/prometheus/client_golang/pull/1971)
- build(deps): bump go.opentelemetry.io/otel/sdk from 1.40.0 to 1.43.0 in /tutorials/whatsup by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1978](https://github.com/prometheus/client_golang/pull/1978)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;1977](https://github.com/prometheus/client_golang/pull/1977)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;1980](https://github.com/prometheus/client_golang/pull/1980)
- examples: add native histogram usage example by [@&#8203;thegdsks](https://github.com/thegdsks) in [#&#8203;1981](https://github.com/prometheus/client_golang/pull/1981)
- chore(ci): add macOS, Windows and arm64 test runners by [@&#8203;kakkoyun](https://github.com/kakkoyun) in [#&#8203;1968](https://github.com/prometheus/client_golang/pull/1968)
- prometheus: honor PidFn on windows and darwin by [@&#8203;Retr0-XD](https://github.com/Retr0-XD) in [#&#8203;1966](https://github.com/prometheus/client_golang/pull/1966)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;1984](https://github.com/prometheus/client_golang/pull/1984)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;1985](https://github.com/prometheus/client_golang/pull/1985)
- promhttp: implement WithXFromContext in terms of WithXFromRequest by [@&#8203;tie](https://github.com/tie) in [#&#8203;1863](https://github.com/prometheus/client_golang/pull/1863)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;1988](https://github.com/prometheus/client_golang/pull/1988)
- Fix bug unit cpu-seconds not a suffix of metric go\_cpu\_classes\_gc\_mark\_assist\_cpu\_seconds by [@&#8203;vesari](https://github.com/vesari) in [#&#8203;1991](https://github.com/prometheus/client_golang/pull/1991)
- build(deps): bump golang.org/x/sys from 0.42.0 to 0.43.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1993](https://github.com/prometheus/client_golang/pull/1993)
- build(deps): bump github.com/klauspost/compress from 1.18.5 to 1.18.6 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1992](https://github.com/prometheus/client_golang/pull/1992)
- build(deps): bump github.com/klauspost/compress from 1.18.5 to 1.18.6 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1995](https://github.com/prometheus/client_golang/pull/1995)
- exp/api/remote: limit request body size in SnappyDecodeMiddleware by [@&#8203;roidelapluie](https://github.com/roidelapluie) in [#&#8203;1996](https://github.com/prometheus/client_golang/pull/1996)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;2001](https://github.com/prometheus/client_golang/pull/2001)
- build(deps): bump the github-actions group across 1 directory with 4 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1994](https://github.com/prometheus/client_golang/pull/1994)
- ci(update-go-versions): declare permissions for the monthly chore PR by [@&#8203;arpitjain099](https://github.com/arpitjain099) in [#&#8203;2003](https://github.com/prometheus/client_golang/pull/2003)
- docs: fix godoc indentation and typos in timer.go and wrap.go by [@&#8203;immanuwell](https://github.com/immanuwell) in [#&#8203;2009](https://github.com/prometheus/client_golang/pull/2009)
- ci: harden actions/checkout with persist-credentials: false by [@&#8203;roidelapluie](https://github.com/roidelapluie) in [#&#8203;2011](https://github.com/prometheus/client_golang/pull/2011)
- fix(registry): prevent file descriptor leak in WriteToTextfile by [@&#8203;ProjectMutilation](https://github.com/ProjectMutilation) in [#&#8203;2010](https://github.com/prometheus/client_golang/pull/2010)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;2008](https://github.com/prometheus/client_golang/pull/2008)
- promhttp: add regression test for concurrent map writes ([#&#8203;1274](https://github.com/prometheus/client_golang/issues/1274)) by [@&#8203;pedrampdd](https://github.com/pedrampdd) in [#&#8203;2000](https://github.com/prometheus/client_golang/pull/2000)
- build(deps): bump github.com/prometheus/common from 0.67.6-0.20260224092343-e4c38a0aea47 to 0.68.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2015](https://github.com/prometheus/client_golang/pull/2015)
- build(deps): bump the github-actions group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2016](https://github.com/prometheus/client_golang/pull/2016)
- build(deps): bump golang.org/x/sys from 0.43.0 to 0.45.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2014](https://github.com/prometheus/client_golang/pull/2014)
- build(deps): bump github.com/prometheus/common from 0.67.5 to 0.68.0 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2017](https://github.com/prometheus/client_golang/pull/2017)
- promhttp: fix grammar in exemplar option doc comments by [@&#8203;s3onghyun](https://github.com/s3onghyun) in [#&#8203;2023](https://github.com/prometheus/client_golang/pull/2023)
- fix: use keyed fields in SamplePair struct literals in api\_test.go by [@&#8203;immanuwell](https://github.com/immanuwell) in [#&#8203;2012](https://github.com/prometheus/client_golang/pull/2012)
- refactor: replace interface{} with any (Go 1.18+) by [@&#8203;MD-Mushfiqur123](https://github.com/MD-Mushfiqur123) in [#&#8203;2021](https://github.com/prometheus/client_golang/pull/2021)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;2013](https://github.com/prometheus/client_golang/pull/2013)
- build(deps): bump github.com/prometheus/common from 0.68.0 to 0.69.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2025](https://github.com/prometheus/client_golang/pull/2025)
- build(deps): bump github.com/prometheus/common from 0.68.0 to 0.69.0 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2027](https://github.com/prometheus/client_golang/pull/2027)
- build(deps): bump golang.org/x/sys from 0.45.0 to 0.46.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2026](https://github.com/prometheus/client_golang/pull/2026)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;2028](https://github.com/prometheus/client_golang/pull/2028)
- build(deps): bump github.com/prometheus/procfs from 0.20.1 to 0.21.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2033](https://github.com/prometheus/client_golang/pull/2033)
- build(deps): bump github.com/klauspost/compress from 1.18.6 to 1.18.7 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2036](https://github.com/prometheus/client_golang/pull/2036)
- build(deps): bump github.com/prometheus/procfs from 0.21.0 to 0.21.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2035](https://github.com/prometheus/client_golang/pull/2035)
- build(deps): bump github.com/klauspost/compress from 1.18.6 to 1.18.7 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2038](https://github.com/prometheus/client_golang/pull/2038)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;2041](https://github.com/prometheus/client_golang/pull/2041)
- fix(api): fall back to GET on forbidden POSTs by [@&#8203;immanuwell](https://github.com/immanuwell) in [#&#8203;2030](https://github.com/prometheus/client_golang/pull/2030)
- build(deps): bump golang.org/x/net from 0.48.0 to 0.55.0 in /tutorials/whatsup by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2042](https://github.com/prometheus/client_golang/pull/2042)
- build(deps): bump the github-actions group across 1 directory with 5 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2043](https://github.com/prometheus/client_golang/pull/2043)
- chores: remove example Dockerfile and container\_description.yaml by [@&#8203;bwplotka](https://github.com/bwplotka) in [#&#8203;2044](https://github.com/prometheus/client_golang/pull/2044)
- Update dependabot config by [@&#8203;SuperQ](https://github.com/SuperQ) in [#&#8203;2046](https://github.com/prometheus/client_golang/pull/2046)
- build(deps): bump github.com/klauspost/compress from 1.18.7 to 1.19.0 in /exp by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2048](https://github.com/prometheus/client_golang/pull/2048)
- build(deps): bump github.com/klauspost/compress from 1.18.7 to 1.19.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2047](https://github.com/prometheus/client_golang/pull/2047)
- promhttp: don't panic when instrumenting with non-exemplar observers by [@&#8203;spor3006](https://github.com/spor3006) in [#&#8203;2005](https://github.com/prometheus/client_golang/pull/2005)
- Replace deprecated model.NameValidationScheme with explicit UTF8Validation by [@&#8203;kakkoyun](https://github.com/kakkoyun) in [#&#8203;2051](https://github.com/prometheus/client_golang/pull/2051)
- test: fix two flaky tests (darwin start\_time regex, memstats HeapReleased drift) by [@&#8203;kakkoyun](https://github.com/kakkoyun) in [#&#8203;2050](https://github.com/prometheus/client_golang/pull/2050)
- fix: correct typos in comments and test error messages by [@&#8203;maxtaran2010](https://github.com/maxtaran2010) in [#&#8203;2049](https://github.com/prometheus/client_golang/pull/2049)
- examples: improve simple main.go example by [@&#8203;dhanudhanushree](https://github.com/dhanudhanushree) in [#&#8203;1999](https://github.com/prometheus/client_golang/pull/1999)
- Synchronize common files from prometheus/prometheus by [@&#8203;prombot](https://github.com/prombot) in [#&#8203;2055](https://github.com/prometheus/client_golang/pull/2055)
- feat(promhttp): add CoalesceGather option to deduplicate concurrent Gather calls by [@&#8203;kakkoyun](https://github.com/kakkoyun) in [#&#8203;1969](https://github.com/prometheus/client_golang/pull/1969)
- build(deps): update all Go dependencies in all go.mod files by [@&#8203;bwplotka](https://github.com/bwplotka) in [#&#8203;2059](https://github.com/prometheus/client_golang/pull/2059)
- Cut v1.24.0-rc.0 by [@&#8203;bwplotka](https://github.com/bwplotka) in [#&#8203;2058](https://github.com/prometheus/client_golang/pull/2058)

</details>

#### New Contributors
* @&#8203;xieyuschen made their first contribution in https://github.com/prometheus/client_golang/pull/1844
* @&#8203;torrca made their first contribution in https://github.com/prometheus/client_golang/pull/1849
* @&#8203;yshngg made their first contribution in https://github.com/prometheus/client_golang/pull/1850
* @&#8203;jotak made their first contribution in https://github.com/prometheus/client_golang/pull/1843
* @&#8203;SungJin1212 made their first contribution in https://github.com/prometheus/client_golang/pull/1878
* @&#8203;github-actions[bot] made their first contribution in https://github.com/prometheus/client_golang/pull/1864
* @&#8203;pipiland2612 made their first contribution in https://github.com/prometheus/client_golang/pull/1888
* @&#8203;fpetkovski made their first contribution in https://github.com/prometheus/client_golang/pull/1889
* @&#8203;karthikkondapally made their first contribution in https://github.com/prometheus/client_golang/pull/1885
* @&#8203;tjhop made their first contribution in https://github.com/prometheus/client_golang/pull/1896
* @&#8203;duricanikolic made their first contribution in https://github.com/prometheus/client_golang/pull/1902
* @&#8203;makasim made their first contribution in https://github.com/prometheus/client_golang/pull/1917
* @&#8203;kgeckhart made their first contribution in https://github.com/prometheus/client_golang/pull/1927
* @&#8203;90ashish made their first contribution in https://github.com/prometheus/client_golang/pull/1929
* @&#8203;manute made their first contribution in https://github.com/prometheus/client_golang/pull/1950
* @&#8203;Saflaski made their first contribution in https://github.com/prometheus/client_golang/pull/1961
* @&#8203;Retr0-XD made their first contribution in https://github.com/prometheus/client_golang/pull/1967
* @&#8203;pedrampdd made their first contribution in https://github.com/prometheus/client_golang/pull/1971
* @&#8203;thegdsks made their first contribution in https://github.com/prometheus/client_golang/pull/1981
* @&#8203;tie made their first contribution in https://github.com/prometheus/client_golang/pull/1863
* @&#8203;arpitjain099 made their first contribution in https://github.com/prometheus/client_golang/pull/2003
* @&#8203;immanuwell made their first contribution in https://github.com/prometheus/client_golang/pull/2009
* @&#8203;ProjectMutilation made their first contribution in https://github.com/prometheus/client_golang/pull/2010
* @&#8203;s3onghyun made their first contribution in https://github.com/prometheus/client_golang/pull/2023
* @&#8203;MD-Mushfiqur123 made their first contribution in https://github.com/prometheus/client_golang/pull/2021
* @&#8203;spor3006 made their first contribution in https://github.com/prometheus/client_golang/pull/2005
* @&#8203;maxtaran2010 made their first contribution in https://github.com/prometheus/client_golang/pull/2049
* @&#8203;dhanudhanushree made their first contribution in https://github.com/prometheus/client_golang/pull/1999

**Full Changelog**: <https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1113
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-26 16:09:02 +00:00
Renovate Bot
de43c84203 fix(deps): update module go.opentelemetry.io/otel to v1.44.0 [security] (#1115)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) | `v1.43.0` → `v1.44.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/go.opentelemetry.io%2fotel/v1.44.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.opentelemetry.io%2fotel/v1.43.0/v1.44.0?slim=true) |

---

### Opentelemetry-go's baggage parsing no longer caps raw header length in go.opentelemetry.io/otel
[CVE-2026-41178](https://nvd.nist.gov/vuln/detail/CVE-2026-41178) / [GHSA-5wrp-cwcj-q835](https://github.com/advisories/GHSA-5wrp-cwcj-q835) / [GO-2026-5158](https://pkg.go.dev/vuln/GO-2026-5158)

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

#### Details
Opentelemetry-go's baggage parsing no longer caps raw header length in go.opentelemetry.io/otel

#### Severity
Unknown

#### References
- [https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-5wrp-cwcj-q835](https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-5wrp-cwcj-q835)
- [https://github.com/open-telemetry/opentelemetry-go/pull/7880](https://github.com/open-telemetry/opentelemetry-go/pull/7880)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-5158) 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>open-telemetry/opentelemetry-go (go.opentelemetry.io/otel)</summary>

### [`v1.44.0`](https://github.com/open-telemetry/opentelemetry-go/releases/tag/v1.44.0): /v0.66.0/v0.20.0/v0.0.17

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

##### Added

- Add `ByteSlice` and `ByteSliceValue` functions for new `BYTESLICE` attribute type in `go.opentelemetry.io/otel/attribute`. ([#&#8203;7948](https://github.com/open-telemetry/opentelemetry-go/issues/7948))
- Apply attribute value limit to the `KindBytes` attribute type in `go.opentelemetry.io/otel/sdk/log`. ([#&#8203;7990](https://github.com/open-telemetry/opentelemetry-go/issues/7990))
- Apply attribute value limit to the `BYTESLICE` attribute type in `go.opentelemetry.io/otel/sdk/trace`. ([#&#8203;7990](https://github.com/open-telemetry/opentelemetry-go/issues/7990))
- Support `BYTESLICE` attributes in `go.opentelemetry.io/otel/trace`. ([#&#8203;8153](https://github.com/open-telemetry/opentelemetry-go/issues/8153))
- Support `BYTESLICE` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlptrace`. ([#&#8203;8153](https://github.com/open-telemetry/opentelemetry-go/issues/8153))
- Support `BYTESLICE` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlplog`. ([#&#8203;8153](https://github.com/open-telemetry/opentelemetry-go/issues/8153))
- Support `BYTESLICE` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric`. ([#&#8203;8153](https://github.com/open-telemetry/opentelemetry-go/issues/8153))
- Support `BYTESLICE` attributes in `go.opentelemetry.io/otel/exporters/zipkin`. ([#&#8203;8153](https://github.com/open-telemetry/opentelemetry-go/issues/8153))
- Add `String` method for `Value` type in `go.opentelemetry.io/otel/attribute`. ([#&#8203;8142](https://github.com/open-telemetry/opentelemetry-go/issues/8142))
- Add `Slice` and `SliceValue` functions for new `SLICE` attribute type in `go.opentelemetry.io/otel/attribute`. ([#&#8203;8166](https://github.com/open-telemetry/opentelemetry-go/issues/8166))
- Support `SLICE` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlptrace`. ([#&#8203;8216](https://github.com/open-telemetry/opentelemetry-go/issues/8216))
- Support `SLICE` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlplog`. ([#&#8203;8216](https://github.com/open-telemetry/opentelemetry-go/issues/8216))
- Support `SLICE` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric`. ([#&#8203;8216](https://github.com/open-telemetry/opentelemetry-go/issues/8216))
- Support `SLICE` attributes in `go.opentelemetry.io/otel/exporters/zipkin`. ([#&#8203;8216](https://github.com/open-telemetry/opentelemetry-go/issues/8216))
- Apply `AttributeValueLengthLimit` to `attribute.SLICE` type attribute values in `go.opentelemetry.io/otel/sdk/trace`, recursively truncating contained string values. ([#&#8203;8217](https://github.com/open-telemetry/opentelemetry-go/issues/8217))
- Add `Error` field on `Record` type in `go.opentelemetry.io/otel/log/logtest`. ([#&#8203;8148](https://github.com/open-telemetry/opentelemetry-go/issues/8148))
- Add `WithMaxRequestSize` option in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc`. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157))
- Add `WithMaxRequestSize` option in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp`. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157))
- Add `WithMaxRequestSize` option in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc`. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157))
- Add `WithMaxRequestSize` option in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp`. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157))
- Add `WithMaxRequestSize` option in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc`. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157))
- Add `WithMaxRequestSize` option in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157))
- Add `Settable` to `go.opentelemetry.io/otel/metric/x` to allow reusing attribute options. ([#&#8203;8178](https://github.com/open-telemetry/opentelemetry-go/issues/8178))
- Add experimental support for splitting metric data across multiple batches in `go.opentelemetry.io/otel/sdk/metric`.
  Set `OTEL_GO_X_METRIC_EXPORT_BATCH_SIZE=<max_size>` to enable for all periodic readers.
  See `go.opentelemetry.io/otel/sdk/metric/internal/x` for feature documentation. ([#&#8203;8071](https://github.com/open-telemetry/opentelemetry-go/issues/8071))
- Add experimental self-observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc`.
  Enable with `OTEL_GO_X_SELF_OBSERVABILITY=true` environment variable.
  See `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc/internal/x` for feature documentation. ([#&#8203;8192](https://github.com/open-telemetry/opentelemetry-go/issues/8192))
- Add experimental self-observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp`.
  Enable with `OTEL_GO_X_SELF_OBSERVABILITY=true` environment variable.
  See `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp/internal/x` for feature documentation. ([#&#8203;8194](https://github.com/open-telemetry/opentelemetry-go/issues/8194))
- Add experimental self-observability metrics in `go.opentelemetry.io/otel/exporters/stdout/stdoutlog`.
  Enable with `OTEL_GO_X_SELF_OBSERVABILITY=true` environment variable.
  See `go.opentelemetry.io/otel/stdout/stdoutlog/internal/x` for feature documentation. ([#&#8203;8263](https://github.com/open-telemetry/opentelemetry-go/issues/8263))
- Add `WithDefaultAttributes` to `go.opentelemetry.io/otel/metric/x` to support setting default attributes on instruments. ([#&#8203;8135](https://github.com/open-telemetry/opentelemetry-go/issues/8135))
- Add `go.opentelemetry.io/otel/semconv/v1.41.0` package.
  The package contains semantic conventions from the `v1.41.0` version of the OpenTelemetry Semantic Conventions.
  See the [migration documentation](./semconv/v1.41.0/MIGRATION.md) for information on how to upgrade from `go.opentelemetry.io/otel/semconv/v1.40.0`. ([#&#8203;8324](https://github.com/open-telemetry/opentelemetry-go/issues/8324))
- Add Observable variants of instruments to `go.opentelemetry.io/otel/semconv/v1.41.0` package. ([#&#8203;8350](https://github.com/open-telemetry/opentelemetry-go/issues/8350))
- Generate explicit histogram bucket boundaries from weaver configuration for HTTP and RPC duration instruments in `go.opentelemetry.io/otel/semconv/v1.41.0`. ([#&#8203;8002](https://github.com/open-telemetry/opentelemetry-go/issues/8002))

##### Changed

- ⚠️ **Breaking Change:** `go.opentelemetry.io/otel/sdk/metric` now applies a default cardinality limit of 2000 to comply with the Metrics SDK specification recommendation.
  New attribute sets are dropped when the cardinality limit is reached. The measurement of these sets are aggregated into a special attribute set containing `attribute.Bool("otel.metric.overflow", true)`.
  This can break users who relied on the previous unlimited default.
  Set `WithCardinalityLimit(0)` or the deprecated `OTEL_GO_X_CARDINALITY_LIMIT=0` environment variable to preserve unlimited cardinality.
  Note that support for `OTEL_GO_X_CARDINALITY_LIMIT` may be removed in a future release. ([#&#8203;8247](https://github.com/open-telemetry/opentelemetry-go/issues/8247))
- `ErrorType` in `go.opentelemetry.io/otel/semconv` now unwraps errors created with `fmt.Errorf` when deriving the `error.type` attribute. ([#&#8203;8133](https://github.com/open-telemetry/opentelemetry-go/issues/8133))
- `go.opentelemetry.io/otel/sdk/log` now unwraps error chains created with `fmt.Errorf` when deriving the `error.type` attribute from errors on log records. ([#&#8203;8133](https://github.com/open-telemetry/opentelemetry-go/issues/8133))
- `Set.MarshalLog` method in `go.opentelemetry.io/otel/attribute` now uses `Value.String` formatting following the [OpenTelemetry AnyValue representation for non-OTLP protocols](https://opentelemetry.io/docs/specs/otel/common/#anyvalue). ([#&#8203;8169](https://github.com/open-telemetry/opentelemetry-go/issues/8169))
- Optimize `go.opentelemetry.io/otel/sdk/metric` to return a drop reservoir and short-circuit `Offer` calls to the exemplar reservoir when `exemplar.AlwaysOffFilter` is configured. ([#&#8203;8211](https://github.com/open-telemetry/opentelemetry-go/issues/8211)) ([#&#8203;8267](https://github.com/open-telemetry/opentelemetry-go/issues/8267))
- Optimize `go.opentelemetry.io/otel/sdk/metric` to return a drop reservoir for asynchronous instruments when `exemplar.TraceBasedFilter` is configured. ([#&#8203;8286](https://github.com/open-telemetry/opentelemetry-go/issues/8286))

##### Deprecated

- Deprecate `Value.Emit` method in `go.opentelemetry.io/otel/attribute`.
  Use `Value.String` instead. ([#&#8203;8176](https://github.com/open-telemetry/opentelemetry-go/issues/8176))

##### Fixed

- Limit OTLP request size to 64 MiB by default in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc`.
  The limit applies before compression, oversized requests are treated as non-retryable errors, and the limit can be configured with the new `WithMaxRequestSize` option. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157), [#&#8203;8365](https://github.com/open-telemetry/opentelemetry-go/issues/8365))
- Limit OTLP request size to 64 MiB by default in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp`.
  The limit applies before compression, oversized requests are treated as non-retryable errors, and the limit can be configured with the new `WithMaxRequestSize` option. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157), [#&#8203;8365](https://github.com/open-telemetry/opentelemetry-go/issues/8365))
- Limit OTLP request size to 64 MiB by default in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc`.
  The limit applies before compression, oversized requests are treated as non-retryable errors, and the limit can be configured with the new `WithMaxRequestSize` option. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157), [#&#8203;8365](https://github.com/open-telemetry/opentelemetry-go/issues/8365))
- Limit OTLP request size to 64 MiB by default in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp`.
  The limit applies before compression, oversized requests are treated as non-retryable errors, and the limit can be configured with the new `WithMaxRequestSize` option. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157), [#&#8203;8365](https://github.com/open-telemetry/opentelemetry-go/issues/8365))
- Limit OTLP request size to 64 MiB by default in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc`.
  The limit applies before compression, oversized requests are treated as non-retryable errors, and the limit can be configured with the new `WithMaxRequestSize` option. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157), [#&#8203;8365](https://github.com/open-telemetry/opentelemetry-go/issues/8365))
- Limit OTLP request size to 64 MiB by default in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`.
  The limit applies before compression, oversized requests are treated as non-retryable errors, and the limit can be configured with the new `WithMaxRequestSize` option. ([#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/issues/8157), [#&#8203;8365](https://github.com/open-telemetry/opentelemetry-go/issues/8365))
- Fix gzipped request body replay on redirect in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp`. ([#&#8203;8135](https://github.com/open-telemetry/opentelemetry-go/issues/8135))
- Fix gzipped request body replay on redirect in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`. ([#&#8203;8152](https://github.com/open-telemetry/opentelemetry-go/issues/8152))
- `go.opentelemetry.io/otel/exporters/prometheus` now uses `Value.String` formatting for label values following the [OpenTelemetry AnyValue representation for non-OTLP protocols](https://opentelemetry.io/docs/specs/otel/common/#anyvalue). ([#&#8203;8170](https://github.com/open-telemetry/opentelemetry-go/issues/8170))
- Propagate errors from the exporter when calling `Shutdown` on `BatchSpanProcessor` in `go.opentelemetry.io/otel/sdk/trace`. ([#&#8203;8197](https://github.com/open-telemetry/opentelemetry-go/issues/8197))
- Fix stale status code reporting on self-observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` and `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`. ([#&#8203;8226](https://github.com/open-telemetry/opentelemetry-go/issues/8226))
- Fix a concurrent `Collect` data race and potential panic in `go.opentelemetry.io/otel/exporters/prometheus` when `WithResourceAsConstantLabels` option is used. ([#&#8203;8227](https://github.com/open-telemetry/opentelemetry-go/issues/8227))
- Fix race condition in `FixedSizeReservoir` in `go.opentelemetry.io/otel/sdk/metric/exemplar` by reverting [#&#8203;7447](https://github.com/open-telemetry/opentelemetry-go/issues/7447). ([#&#8203;8249](https://github.com/open-telemetry/opentelemetry-go/issues/8249))
- Fix `FixedSizeReservoir` in `go.opentelemetry.io/otel/sdk/metric/exemplar` to safely handle zero size.
  A capacity check in the constructor initializes the reservoir safely and skips initialization for zero-cap; early returns in `Offer()` and `Collect()` ensure no-op behavior. ([#&#8203;8295](https://github.com/open-telemetry/opentelemetry-go/issues/8295))
- Fix counting of spans and logs in self-observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc`, `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp`, `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc`, and `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`. ([#&#8203;8254](https://github.com/open-telemetry/opentelemetry-go/issues/8254))
- Drop conflicting scope attributes named `name`, `version`, or `schema_url` from metric labels in `go.opentelemetry.io/otel/exporters/prometheus`, preserving the dedicated `otel_scope_name`, `otel_scope_version`, and `otel_scope_schema_url` labels. ([#&#8203;8264](https://github.com/open-telemetry/opentelemetry-go/issues/8264))
- Close schema files opened by `ParseFile` in `go.opentelemetry.io/otel/schema/v1.0` and `go.opentelemetry.io/otel/schema/v1.1`. ([GHSA-995v-fvrw-c78m](https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-995v-fvrw-c78m))
- Enforce the 8192-byte baggage size limit during extraction/parsing, changing behavior when the limit is exceeded in `go.opentelemetry.io/otel/baggage` and `go.opentelemetry.io/otel/propagation`. ([#&#8203;8222](https://github.com/open-telemetry/opentelemetry-go/issues/8222))
- Fix `go.opentelemetry.io/otel/semconv/v1.41.0` to include `Attr*` helper methods for required attributes on observable instruments. ([#&#8203;8361](https://github.com/open-telemetry/opentelemetry-go/issues/8361))
- Limit baggage extraction error reporting in `go.opentelemetry.io/otel/propagation` to prevent malformed or oversized baggage headers from flooding logs. ([GHSA-5wrp-cwcj-q835](https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-5wrp-cwcj-q835))

#### What's Changed

- Document how to implement experimental features by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8124](https://github.com/open-telemetry/opentelemetry-go/pull/8124)
- Add support for experimental options in the metrics API by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8111](https://github.com/open-telemetry/opentelemetry-go/pull/8111)
- fix(deps): update github.com/opentracing-contrib/go-grpc/test digest to [`e5db982`](https://github.com/open-telemetry/opentelemetry-go/commit/e5db982) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8136](https://github.com/open-telemetry/opentelemetry-go/pull/8136)
- fix(deps): update github.com/opentracing-contrib/go-grpc/test digest to [`32cd848`](https://github.com/open-telemetry/opentelemetry-go/commit/32cd848) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8141](https://github.com/open-telemetry/opentelemetry-go/pull/8141)
- fix(deps): update googleapis to [`6f92a3b`](https://github.com/open-telemetry/opentelemetry-go/commit/6f92a3b) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8140](https://github.com/open-telemetry/opentelemetry-go/pull/8140)
- chore(deps): update module github.com/jgautheron/goconst to v1.10.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8134](https://github.com/open-telemetry/opentelemetry-go/pull/8134)
- attribute: add BYTESLICE type support by [@&#8203;NesterovYehor](https://github.com/NesterovYehor) in [#&#8203;7948](https://github.com/open-telemetry/opentelemetry-go/pull/7948)
- unwrap error chains created with fmt.Errorf by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8133](https://github.com/open-telemetry/opentelemetry-go/pull/8133)
- log/logtest: add Error field to Record type by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8148](https://github.com/open-telemetry/opentelemetry-go/pull/8148)
- attribute: add String method for Value type by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8142](https://github.com/open-telemetry/opentelemetry-go/pull/8142)
- fix(deps): update module golang.org/x/sys to v0.43.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8156](https://github.com/open-telemetry/opentelemetry-go/pull/8156)
- chore(deps): update codspeedhq/action action to v4.13.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8155](https://github.com/open-telemetry/opentelemetry-go/pull/8155)
- fix(otlploghttp): replay gzipped bodies on redirect by [@&#8203;MrAlias](https://github.com/MrAlias) in [#&#8203;8152](https://github.com/open-telemetry/opentelemetry-go/pull/8152)
- Improve test coverage for exponential histogram edge cases by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8129](https://github.com/open-telemetry/opentelemetry-go/pull/8129)
- Add example test for the prometheus exporter by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8137](https://github.com/open-telemetry/opentelemetry-go/pull/8137)
- chore(deps): update golang.org/x/telemetry digest to [`93c7c8a`](https://github.com/open-telemetry/opentelemetry-go/commit/93c7c8a) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8158](https://github.com/open-telemetry/opentelemetry-go/pull/8158)
- chore(deps): update module github.com/mattn/go-runewidth to v0.0.23 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8161](https://github.com/open-telemetry/opentelemetry-go/pull/8161)
- chore(deps): update module github.com/mattn/go-isatty to v0.0.21 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8159](https://github.com/open-telemetry/opentelemetry-go/pull/8159)
- fix(deps): update github.com/opentracing-contrib/go-grpc/test digest to [`6b4d2bc`](https://github.com/open-telemetry/opentelemetry-go/commit/6b4d2bc) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8160](https://github.com/open-telemetry/opentelemetry-go/pull/8160)
- Add experimental support for batching in periodic reader by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8071](https://github.com/open-telemetry/opentelemetry-go/pull/8071)
- chore(deps): update golang.org/x by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8165](https://github.com/open-telemetry/opentelemetry-go/pull/8165)
- Support `BYTESLICE` attributes across trace and exporter paths by [@&#8203;MrAlias](https://github.com/MrAlias) in [#&#8203;8153](https://github.com/open-telemetry/opentelemetry-go/pull/8153)
- chore(deps): update golang.org/x by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8171](https://github.com/open-telemetry/opentelemetry-go/pull/8171)
- fix(deps): update module golang.org/x/tools to v0.44.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8173](https://github.com/open-telemetry/opentelemetry-go/pull/8173)
- metricdatatest: support BYTESLICE attribute comparisons by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8167](https://github.com/open-telemetry/opentelemetry-go/pull/8167)
- test: add test case for ByteSlice in TestValueFromAttribute by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8168](https://github.com/open-telemetry/opentelemetry-go/pull/8168)
- attribute: Set.MarshalLog to use Value.String instead of Value.Emit by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8169](https://github.com/open-telemetry/opentelemetry-go/pull/8169)
- prometheus: use Value.String instead of Value.Emit by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8170](https://github.com/open-telemetry/opentelemetry-go/pull/8170)
- fix(deps): update golang.org/x to [`746e56f`](https://github.com/open-telemetry/opentelemetry-go/commit/746e56f) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8175](https://github.com/open-telemetry/opentelemetry-go/pull/8175)
- Add support for the development attributes advisory parameter by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8135](https://github.com/open-telemetry/opentelemetry-go/pull/8135)
- chore(deps): update actions/upload-artifact action to v7.0.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8177](https://github.com/open-telemetry/opentelemetry-go/pull/8177)
- attribute: deprecate Value.Emit by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8176](https://github.com/open-telemetry/opentelemetry-go/pull/8176)
- chore(deps): update module github.com/manuelarte/funcorder to v0.6.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8181](https://github.com/open-telemetry/opentelemetry-go/pull/8181)
- chore(deps): update module github.com/ashanbrown/makezero/v2 to v2.2.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8180](https://github.com/open-telemetry/opentelemetry-go/pull/8180)
- chore(deps): update module github.com/ashanbrown/forbidigo/v2 to v2.3.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8182](https://github.com/open-telemetry/opentelemetry-go/pull/8182)
- fix(deps): update module go.opentelemetry.io/collector/pdata to v1.56.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8184](https://github.com/open-telemetry/opentelemetry-go/pull/8184)
- Update semconv template and 1.40.0 to use Enabled for metrics by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8172](https://github.com/open-telemetry/opentelemetry-go/pull/8172)
- Add x.Settable to allow reusing attribute options by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8178](https://github.com/open-telemetry/opentelemetry-go/pull/8178)
- chore(deps): update actions/cache action to v5.0.5 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8187](https://github.com/open-telemetry/opentelemetry-go/pull/8187)
- fix(deps): update googleapis to [`3e5c5a5`](https://github.com/open-telemetry/opentelemetry-go/commit/3e5c5a5) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8190](https://github.com/open-telemetry/opentelemetry-go/pull/8190)
- fix(otlpmetrichttp): replay gzipped bodies on redirect by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8185](https://github.com/open-telemetry/opentelemetry-go/pull/8185)
- fix(deps): update module golang.org/x/vuln to v1.2.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8193](https://github.com/open-telemetry/opentelemetry-go/pull/8193)
- fix(deps): update googleapis to [`afd174a`](https://github.com/open-telemetry/opentelemetry-go/commit/afd174a) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8195](https://github.com/open-telemetry/opentelemetry-go/pull/8195)
- chore(deps): update module github.com/dave/dst to v0.27.4 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8198](https://github.com/open-telemetry/opentelemetry-go/pull/8198)
- Fix exemplar tests in containerized environments by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8188](https://github.com/open-telemetry/opentelemetry-go/pull/8188)
- Update contributing to recommend using Enabled by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8189](https://github.com/open-telemetry/opentelemetry-go/pull/8189)
- otlptracehttp: reset pooled gzip writer before reuse by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8196](https://github.com/open-telemetry/opentelemetry-go/pull/8196)
- chore(deps): update golang.org/x/telemetry digest to [`fac6e1c`](https://github.com/open-telemetry/opentelemetry-go/commit/fac6e1c) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8202](https://github.com/open-telemetry/opentelemetry-go/pull/8202)
- fix(deps): update module github.com/opentracing-contrib/go-grpc to v0.1.3 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8207](https://github.com/open-telemetry/opentelemetry-go/pull/8207)
- fix(deps): update github.com/opentracing-contrib/go-grpc/test digest to [`07c9668`](https://github.com/open-telemetry/opentelemetry-go/commit/07c9668) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8206](https://github.com/open-telemetry/opentelemetry-go/pull/8206)
- attribute: make TestHashKVs linear-time by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8204](https://github.com/open-telemetry/opentelemetry-go/pull/8204)
- chore(deps): update github/codeql-action action to v4.35.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8208](https://github.com/open-telemetry/opentelemetry-go/pull/8208)
- sdk/trace: propagate SpanExporter.Shutdown error from BatchSpanProcessor by [@&#8203;alliasgher](https://github.com/alliasgher) in [#&#8203;8197](https://github.com/open-telemetry/opentelemetry-go/pull/8197)
- add GitHub Copilot code review instructions by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8212](https://github.com/open-telemetry/opentelemetry-go/pull/8212)
- chore(deps): update module github.com/grpc-ecosystem/grpc-gateway/v2 to v2.29.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8214](https://github.com/open-telemetry/opentelemetry-go/pull/8214)
- attribute: add SLICE type support by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8166](https://github.com/open-telemetry/opentelemetry-go/pull/8166)
- Fix typos found by copilot by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8221](https://github.com/open-telemetry/opentelemetry-go/pull/8221)
- chore(deps): update module github.com/go-git/go-git/v5 to v5.18.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8223](https://github.com/open-telemetry/opentelemetry-go/pull/8223)
- docs: add agent guide for autonomous coding agents by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8215](https://github.com/open-telemetry/opentelemetry-go/pull/8215)
- test: truncate attribute string values using Unicode rune count by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8219](https://github.com/open-telemetry/opentelemetry-go/pull/8219)
- sdk/trace: apply AttributeValueLengthLimit to attribute.SLICE by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8217](https://github.com/open-telemetry/opentelemetry-go/pull/8217)
- chore(deps): update module github.com/dlclark/regexp2 to v1.12.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8229](https://github.com/open-telemetry/opentelemetry-go/pull/8229)
- prometheus: fix Collect data race for constant resource labels by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8227](https://github.com/open-telemetry/opentelemetry-go/pull/8227)
- exporters: support SLICE attributes by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8216](https://github.com/open-telemetry/opentelemetry-go/pull/8216)
- chore(deps): update codspeedhq/action action to v4.14.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8234](https://github.com/open-telemetry/opentelemetry-go/pull/8234)
- Fix stale status code reporting on self-observability metrics by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8226](https://github.com/open-telemetry/opentelemetry-go/pull/8226)
- fix(deps): update googleapis to [`e10c466`](https://github.com/open-telemetry/opentelemetry-go/commit/e10c466) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8241](https://github.com/open-telemetry/opentelemetry-go/pull/8241)
- fix(deps): update build-tools to v0.30.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8244](https://github.com/open-telemetry/opentelemetry-go/pull/8244)
- \[chore] changelog: re-run workflow on PR title edits by [@&#8203;cijothomas](https://github.com/cijothomas) in [#&#8203;8246](https://github.com/open-telemetry/opentelemetry-go/pull/8246)
- stdlog observ: remove partial success handling  by [@&#8203;yumosx](https://github.com/yumosx) in [#&#8203;8174](https://github.com/open-telemetry/opentelemetry-go/pull/8174)
- feat: add self-observability metrics to otlpmetrichttp metric exporters by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8194](https://github.com/open-telemetry/opentelemetry-go/pull/8194)
- chore(deps): update golang.org/x/telemetry digest to [`392afab`](https://github.com/open-telemetry/opentelemetry-go/commit/392afab) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8248](https://github.com/open-telemetry/opentelemetry-go/pull/8248)
- Use a DropReservoir when an exemplar.AlwaysOffFilter is provided by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8211](https://github.com/open-telemetry/opentelemetry-go/pull/8211)
- metric: clarify sync vs observable Gauge in package godoc by [@&#8203;alliasgher](https://github.com/alliasgher) in [#&#8203;8225](https://github.com/open-telemetry/opentelemetry-go/pull/8225)
- sdk/metric: apply default cardinality limit of 2000 by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8247](https://github.com/open-telemetry/opentelemetry-go/pull/8247)
- Revert "Optimize fixedsize reservoir ([#&#8203;7447](https://github.com/open-telemetry/opentelemetry-go/issues/7447))" by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8249](https://github.com/open-telemetry/opentelemetry-go/pull/8249)
- fix(deps): update module golang.org/x/vuln to v1.3.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8256](https://github.com/open-telemetry/opentelemetry-go/pull/8256)
- chore(deps): update otel/weaver docker tag to v0.23.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8255](https://github.com/open-telemetry/opentelemetry-go/pull/8255)
- Run benchmarks using Settable for more accurate comparrisons by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8252](https://github.com/open-telemetry/opentelemetry-go/pull/8252)
- Add MaxRequestSize option to OTLP exporters by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8157](https://github.com/open-telemetry/opentelemetry-go/pull/8157)
- fix counting of spans/logs in self-observability metrics in otlp trace and log exporters by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8254](https://github.com/open-telemetry/opentelemetry-go/pull/8254)
- fix(deps): update github.com/opentracing-contrib/go-grpc/test digest to [`2f88a58`](https://github.com/open-telemetry/opentelemetry-go/commit/2f88a58) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8260](https://github.com/open-telemetry/opentelemetry-go/pull/8260)
- chore(deps): update golang.org/x/telemetry digest to [`329d219`](https://github.com/open-telemetry/opentelemetry-go/commit/329d219) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8259](https://github.com/open-telemetry/opentelemetry-go/pull/8259)
- chore(deps): update module github.com/sourcegraph/go-diff to v0.8.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8262](https://github.com/open-telemetry/opentelemetry-go/pull/8262)
- chore(deps): update module github.com/mattn/go-isatty to v0.0.22 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8265](https://github.com/open-telemetry/opentelemetry-go/pull/8265)
- chore(deps): update module go.uber.org/zap to v1.28.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8269](https://github.com/open-telemetry/opentelemetry-go/pull/8269)
- fix(deps): update googleapis to [`7cedc36`](https://github.com/open-telemetry/opentelemetry-go/commit/7cedc36) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8266](https://github.com/open-telemetry/opentelemetry-go/pull/8266)
- chore(deps): update module github.com/securego/gosec/v2 to v2.26.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8270](https://github.com/open-telemetry/opentelemetry-go/pull/8270)
- fix(deps): update module go.opentelemetry.io/collector/pdata to v1.57.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8275](https://github.com/open-telemetry/opentelemetry-go/pull/8275)
- chore(deps): update codspeedhq/action action to v4.15.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8272](https://github.com/open-telemetry/opentelemetry-go/pull/8272)
- chore(deps): update golang.org/x/telemetry digest to [`76f71b9`](https://github.com/open-telemetry/opentelemetry-go/commit/76f71b9) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8271](https://github.com/open-telemetry/opentelemetry-go/pull/8271)
- Apply attribute value limit for BYTESLICE and KindBytes by [@&#8203;NesterovYehor](https://github.com/NesterovYehor) in [#&#8203;7990](https://github.com/open-telemetry/opentelemetry-go/pull/7990)
- chore(deps): update module github.com/alecthomas/chroma/v2 to v2.24.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8277](https://github.com/open-telemetry/opentelemetry-go/pull/8277)
- chore(deps): update module github.com/fsnotify/fsnotify to v1.10.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8280](https://github.com/open-telemetry/opentelemetry-go/pull/8280)
- chore(deps): update module github.com/alecthomas/chroma/v2 to v2.24.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8281](https://github.com/open-telemetry/opentelemetry-go/pull/8281)
- Prometheus Exporter: Drop Scope attributes name, version and schema\_url by [@&#8203;ArthurSens](https://github.com/ArthurSens) in [#&#8203;8264](https://github.com/open-telemetry/opentelemetry-go/pull/8264)
- attribute: split HashKVs benchmark by value type by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8268](https://github.com/open-telemetry/opentelemetry-go/pull/8268)
- \[chore] metric: document Enabled and WithAttributeSet in package docs by [@&#8203;cijothomas](https://github.com/cijothomas) in [#&#8203;8245](https://github.com/open-telemetry/opentelemetry-go/pull/8245)
- chore(deps): update module github.com/bombsimon/wsl/v5 to v5.8.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8287](https://github.com/open-telemetry/opentelemetry-go/pull/8287)
- fix(deps): update module github.com/masterminds/semver/v3 to v3.5.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8283](https://github.com/open-telemetry/opentelemetry-go/pull/8283)
- chore(deps): update module github.com/pjbgf/sha1cd to v0.6.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8288](https://github.com/open-telemetry/opentelemetry-go/pull/8288)
- Optimize metrics sdk measurement with AlwaysOff exemplar filter by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8267](https://github.com/open-telemetry/opentelemetry-go/pull/8267)
- fix(deps): update module github.com/golangci/golangci-lint/v2 to v2.12.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8290](https://github.com/open-telemetry/opentelemetry-go/pull/8290)
- chore(deps): update github/codeql-action action to v4.35.3 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8289](https://github.com/open-telemetry/opentelemetry-go/pull/8289)
- chore(deps): update github.com/charmbracelet/ultraviolet digest to [`6603726`](https://github.com/open-telemetry/opentelemetry-go/commit/6603726) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8291](https://github.com/open-telemetry/opentelemetry-go/pull/8291)
- chore(deps): update github.com/golangci/rowserrcheck digest to [`8d53bbc`](https://github.com/open-telemetry/opentelemetry-go/commit/8d53bbc) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8292](https://github.com/open-telemetry/opentelemetry-go/pull/8292)
- chore(deps): update module github.com/pelletier/go-toml/v2 to v2.3.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8293](https://github.com/open-telemetry/opentelemetry-go/pull/8293)
- fix(deps): update module github.com/golangci/golangci-lint/v2 to v2.12.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8294](https://github.com/open-telemetry/opentelemetry-go/pull/8294)
- chore(deps): update module github.com/ryancurrah/gomodguard/v2 to v2.1.3 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8296](https://github.com/open-telemetry/opentelemetry-go/pull/8296)
- fix(deps): update module google.golang.org/grpc to v1.81.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8298](https://github.com/open-telemetry/opentelemetry-go/pull/8298)
- chore(deps): update module github.com/fsnotify/fsnotify to v1.10.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8300](https://github.com/open-telemetry/opentelemetry-go/pull/8300)
- chore(deps): update module github.com/uudashr/iface to v1.4.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8301](https://github.com/open-telemetry/opentelemetry-go/pull/8301)
- fix(deps): update googleapis to [`60b97b3`](https://github.com/open-telemetry/opentelemetry-go/commit/60b97b3) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8303](https://github.com/open-telemetry/opentelemetry-go/pull/8303)
- chore(deps): update codspeedhq/action action to v4.15.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8307](https://github.com/open-telemetry/opentelemetry-go/pull/8307)
- fix(deps): update module github.com/golangci/golangci-lint/v2 to v2.12.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8308](https://github.com/open-telemetry/opentelemetry-go/pull/8308)
- chore(deps): update golang.org/x/telemetry digest to [`5a0966d`](https://github.com/open-telemetry/opentelemetry-go/commit/5a0966d) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8310](https://github.com/open-telemetry/opentelemetry-go/pull/8310)
- chore(deps): update module github.com/ghostiam/protogetter to v0.3.21 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8311](https://github.com/open-telemetry/opentelemetry-go/pull/8311)
- chore(deps): update module github.com/go-git/go-billy/v5 to v5.9.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8312](https://github.com/open-telemetry/opentelemetry-go/pull/8312)
- chore(deps): update module github.com/jgautheron/goconst to v1.10.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8315](https://github.com/open-telemetry/opentelemetry-go/pull/8315)
- chore(deps): update github/codeql-action action to v4.35.4 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8318](https://github.com/open-telemetry/opentelemetry-go/pull/8318)
- chore(deps): update golang.org/x/telemetry digest to [`e88f59f`](https://github.com/open-telemetry/opentelemetry-go/commit/e88f59f) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8317](https://github.com/open-telemetry/opentelemetry-go/pull/8317)
- chore(deps): update module github.com/raeperd/recvcheck to v0.3.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8314](https://github.com/open-telemetry/opentelemetry-go/pull/8314)
- fix(deps): update module golang.org/x/sys to v0.44.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8322](https://github.com/open-telemetry/opentelemetry-go/pull/8322)
- chore(deps): update module github.com/go-git/go-git/v5 to v5.19.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8313](https://github.com/open-telemetry/opentelemetry-go/pull/8313)
- chore(deps): update module github.com/abirdcfly/dupword to v0.1.8 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8316](https://github.com/open-telemetry/opentelemetry-go/pull/8316)
- chore(deps): update golang.org/x by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8323](https://github.com/open-telemetry/opentelemetry-go/pull/8323)
- docs: Expand SIG meeting welcoming language by [@&#8203;cijothomas](https://github.com/cijothomas) in [#&#8203;8319](https://github.com/open-telemetry/opentelemetry-go/pull/8319)
- chore(deps): update module mvdan.cc/gofumpt to v0.10.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8304](https://github.com/open-telemetry/opentelemetry-go/pull/8304)
- chore(deps): update golang.org/x by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8325](https://github.com/open-telemetry/opentelemetry-go/pull/8325)
- chore(deps): update golang.org/x/telemetry digest to [`42602be`](https://github.com/open-telemetry/opentelemetry-go/commit/42602be) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8326](https://github.com/open-telemetry/opentelemetry-go/pull/8326)
- Fix benchmark ci by [@&#8203;XSAM](https://github.com/XSAM) in [#&#8203;8282](https://github.com/open-telemetry/opentelemetry-go/pull/8282)
- fix(deps): update golang.org/x by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8327](https://github.com/open-telemetry/opentelemetry-go/pull/8327)
- chore(deps): update module go.opentelemetry.io/collector/featuregate to v1.58.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8328](https://github.com/open-telemetry/opentelemetry-go/pull/8328)
- fix(deps): update module go.opentelemetry.io/collector/pdata to v1.58.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8329](https://github.com/open-telemetry/opentelemetry-go/pull/8329)
- chore(deps): update github.com/charmbracelet/ultraviolet digest to [`c840852`](https://github.com/open-telemetry/opentelemetry-go/commit/c840852) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8331](https://github.com/open-telemetry/opentelemetry-go/pull/8331)
- fix(deps): update googleapis to [`3700d41`](https://github.com/open-telemetry/opentelemetry-go/commit/3700d41) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8332](https://github.com/open-telemetry/opentelemetry-go/pull/8332)
- fix: clear cached objects to enable GC by [@&#8203;ash2k](https://github.com/ash2k) in [#&#8203;8233](https://github.com/open-telemetry/opentelemetry-go/pull/8233)
- Generate and upgrade to `semconv/v1.41.0` by [@&#8203;MrAlias](https://github.com/MrAlias) in [#&#8203;8324](https://github.com/open-telemetry/opentelemetry-go/pull/8324)
- chore(deps): update module github.com/go-git/go-git/v5 to v5.19.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8345](https://github.com/open-telemetry/opentelemetry-go/pull/8345)
- chore: Skip benchmark workflow when only non-Go files change by [@&#8203;cijothomas](https://github.com/cijothomas) in [#&#8203;8346](https://github.com/open-telemetry/opentelemetry-go/pull/8346)
- chore(deps): update github/codeql-action action to v4.35.5 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8341](https://github.com/open-telemetry/opentelemetry-go/pull/8341)
- Add max baggage length as limitation by [@&#8203;XSAM](https://github.com/XSAM) in [#&#8203;8222](https://github.com/open-telemetry/opentelemetry-go/pull/8222)
- Generating histogram boundaries from weaver.yaml by [@&#8203;itssaharsh](https://github.com/itssaharsh) in [#&#8203;8015](https://github.com/open-telemetry/opentelemetry-go/pull/8015)
- chore(deps): update codecov/codecov-action action to v6.0.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8342](https://github.com/open-telemetry/opentelemetry-go/pull/8342)
- chore(deps): update module github.com/kisielk/errcheck to v1.20.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8333](https://github.com/open-telemetry/opentelemetry-go/pull/8333)
- Add observable instrument variants to semconv v1.41.0 by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8350](https://github.com/open-telemetry/opentelemetry-go/pull/8350)
- fix(semconv): clear pooled slices to enable GC by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8352](https://github.com/open-telemetry/opentelemetry-go/pull/8352)
- chore(deps): update actions/stale action to v10.3.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8355](https://github.com/open-telemetry/opentelemetry-go/pull/8355)
- chore(deps): update module github.com/uudashr/iface to v1.4.4 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8335](https://github.com/open-telemetry/opentelemetry-go/pull/8335)
- fix(deps): update module google.golang.org/grpc to v1.81.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8340](https://github.com/open-telemetry/opentelemetry-go/pull/8340)
- chore(deps): update module 4d63.com/gocheckcompilerdirectives to v1.4.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8334](https://github.com/open-telemetry/opentelemetry-go/pull/8334)
- chore(deps): update golang.org/x/telemetry digest to [`eab6ae5`](https://github.com/open-telemetry/opentelemetry-go/commit/eab6ae5) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8348](https://github.com/open-telemetry/opentelemetry-go/pull/8348)
- fix(deps): update googleapis to [`aa98bba`](https://github.com/open-telemetry/opentelemetry-go/commit/aa98bba) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8344](https://github.com/open-telemetry/opentelemetry-go/pull/8344)
- Fix semconv generation to include Attr helpers for required attributes on observable instruments by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8361](https://github.com/open-telemetry/opentelemetry-go/pull/8361)
- fix(deps): update golang.org/x by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8363](https://github.com/open-telemetry/opentelemetry-go/pull/8363)
- chore(deps): update module github.com/antonboom/nilnil to v1.1.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8360](https://github.com/open-telemetry/opentelemetry-go/pull/8360)
- chore(deps): update module github.com/antonboom/errname to v1.1.2 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8359](https://github.com/open-telemetry/opentelemetry-go/pull/8359)
- chore(deps): update module github.com/uudashr/iface to v1.5.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8362](https://github.com/open-telemetry/opentelemetry-go/pull/8362)
- Fix Extrema failure test by [@&#8203;mujib77](https://github.com/mujib77) in [#&#8203;8338](https://github.com/open-telemetry/opentelemetry-go/pull/8338)
- Fix receiver-naming issues from revive by [@&#8203;mmorel-35](https://github.com/mmorel-35) in [#&#8203;8093](https://github.com/open-telemetry/opentelemetry-go/pull/8093)
- docs: clarify that View attribute filters do not apply to Exemplars by [@&#8203;Dipanshusinghh](https://github.com/Dipanshusinghh) in [#&#8203;8339](https://github.com/open-telemetry/opentelemetry-go/pull/8339)
- Disable exemplar reservoir for asynchronous instruments by default by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8286](https://github.com/open-telemetry/opentelemetry-go/pull/8286)
- fix: handle FixedSizeReservoir size=0 without panic by [@&#8203;muskiteer](https://github.com/muskiteer) in [#&#8203;8295](https://github.com/open-telemetry/opentelemetry-go/pull/8295)
- fix(deps): update module go.opentelemetry.io/collector/pdata to v1.59.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8373](https://github.com/open-telemetry/opentelemetry-go/pull/8373)
- chore(deps): update github/codeql-action action to v4.36.0 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8367](https://github.com/open-telemetry/opentelemetry-go/pull/8367)
- chore(deps): update module github.com/clickhouse/clickhouse-go-linter to v1.2.1 by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8366](https://github.com/open-telemetry/opentelemetry-go/pull/8366)
- chore(deps): update github.com/charmbracelet/ultraviolet digest to [`948f455`](https://github.com/open-telemetry/opentelemetry-go/commit/948f455) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8374](https://github.com/open-telemetry/opentelemetry-go/pull/8374)
- fix(deps): update googleapis to [`0a33c5d`](https://github.com/open-telemetry/opentelemetry-go/commit/0a33c5d) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8369](https://github.com/open-telemetry/opentelemetry-go/pull/8369)
- add self observability for stdout exporter by [@&#8203;yumosx](https://github.com/yumosx) in [#&#8203;8263](https://github.com/open-telemetry/opentelemetry-go/pull/8263)
- sdk/metric: document unit-sensitivity of DefaultAggregationSelector by [@&#8203;alliasgher](https://github.com/alliasgher) in [#&#8203;8224](https://github.com/open-telemetry/opentelemetry-go/pull/8224)
- semconvkit: add invariant test for histogram-exclusion rule by [@&#8203;thealpha93](https://github.com/thealpha93) in [#&#8203;8370](https://github.com/open-telemetry/opentelemetry-go/pull/8370)
- exporters/otlp: default max request size to 64 MiB by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8365](https://github.com/open-telemetry/opentelemetry-go/pull/8365)
- fix(deps): update googleapis to [`3dc84a4`](https://github.com/open-telemetry/opentelemetry-go/commit/3dc84a4) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8375](https://github.com/open-telemetry/opentelemetry-go/pull/8375)
- fix(deps): update golang.org/x by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8377](https://github.com/open-telemetry/opentelemetry-go/pull/8377)
- feat: add self-observability metrics to otlpmetricgrpc metric exporters by [@&#8203;dashpole](https://github.com/dashpole) in [#&#8203;8192](https://github.com/open-telemetry/opentelemetry-go/pull/8192)
- chore(deps): update golang.org/x/telemetry digest to [`5997936`](https://github.com/open-telemetry/opentelemetry-go/commit/5997936) by [@&#8203;renovate](https://github.com/renovate)\[bot] in [#&#8203;8379](https://github.com/open-telemetry/opentelemetry-go/pull/8379)
- Release 1.44.0 by [@&#8203;pellared](https://github.com/pellared) in [#&#8203;8376](https://github.com/open-telemetry/opentelemetry-go/pull/8376)

#### New Contributors

- [@&#8203;alliasgher](https://github.com/alliasgher) made their first contribution in [#&#8203;8197](https://github.com/open-telemetry/opentelemetry-go/pull/8197)
- [@&#8203;mujib77](https://github.com/mujib77) made their first contribution in [#&#8203;8338](https://github.com/open-telemetry/opentelemetry-go/pull/8338)
- [@&#8203;Dipanshusinghh](https://github.com/Dipanshusinghh) made their first contribution in [#&#8203;8339](https://github.com/open-telemetry/opentelemetry-go/pull/8339)
- [@&#8203;muskiteer](https://github.com/muskiteer) made their first contribution in [#&#8203;8295](https://github.com/open-telemetry/opentelemetry-go/pull/8295)
- [@&#8203;thealpha93](https://github.com/thealpha93) made their first contribution in [#&#8203;8370](https://github.com/open-telemetry/opentelemetry-go/pull/8370)

**Full Changelog**: <https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0>

</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 <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1115
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-26 15:52:44 +00:00
Renovate Bot
3c5ef1721a fix(deps): update module golang.org/x/net to v0.56.0 [security] (#1116)
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.54.0` → `v0.56.0`](https://cs.opensource.google/go/x/net/+/refs/tags/v0.54.0...refs/tags/v0.56.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fnet/v0.56.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fnet/v0.54.0/v0.56.0?slim=true) |

---

### Go Net HTML parser is vulnerable to denial of service
[CVE-2026-25680](https://nvd.nist.gov/vuln/detail/CVE-2026-25680) / [GHSA-5cv4-jp36-h3mw](https://github.com/advisories/GHSA-5cv4-jp36-h3mw) / [GO-2026-5028](https://pkg.go.dev/vuln/GO-2026-5028)

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

#### Details
In Go Net (`golang.org/x/net`) before verion 0.55.0, parsing arbitrary HTML can consume excessive CPU time, possibly leading to denial of service.

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

#### References
- [https://nvd.nist.gov/vuln/detail/CVE-2026-25680](https://nvd.nist.gov/vuln/detail/CVE-2026-25680)
- [https://go.dev/cl/781702](https://go.dev/cl/781702)
- [https://go.dev/issue/79573](https://go.dev/issue/79573)
- [08be507abc)
- [https://go.googlesource.com/net/+/refs/tags/v0.55.0](https://go.googlesource.com/net/+/refs/tags/v0.55.0)
- [https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8](https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8)
- [https://pkg.go.dev/vuln/GO-2026-5028](https://pkg.go.dev/vuln/GO-2026-5028)
- [cs.opensource.google/go/x/net](cs.opensource.google/go/x/net)

This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-5cv4-jp36-h3mw) 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>

---

### Invoking incorrect handling of namespaced elements in foreign content in golang.org/x/net/html
[CVE-2026-42506](https://nvd.nist.gov/vuln/detail/CVE-2026-42506) / [GO-2026-5025](https://pkg.go.dev/vuln/GO-2026-5025)

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

#### Details
Parsing arbitrary HTML which is then rendered using Render can result in an unexpected HTML tree. This can be leveraged to execute XSS attacks in applications that attempt to sanitize input HTML before rendering.

#### Severity
Unknown

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

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

---

### 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)

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>

---

### Invoking incorrect handling of HTML elements in foreign content in golang.org/x/net/html
[CVE-2026-42502](https://nvd.nist.gov/vuln/detail/CVE-2026-42502) / [GO-2026-5027](https://pkg.go.dev/vuln/GO-2026-5027)

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

#### Details
Parsing arbitrary HTML which is then rendered using Render can result in an unexpected HTML tree. This can be leveraged to execute XSS attacks in applications that attempt to sanitize input HTML before rendering.

#### Severity
Unknown

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

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

---

### Invoking denial of service when parsing arbitrary HTML in golang.org/x/net/html
[CVE-2026-25680](https://nvd.nist.gov/vuln/detail/CVE-2026-25680) / [GHSA-5cv4-jp36-h3mw](https://github.com/advisories/GHSA-5cv4-jp36-h3mw) / [GO-2026-5028](https://pkg.go.dev/vuln/GO-2026-5028)

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

#### Details
Parsing arbitrary HTML can consume excessive CPU time, possibly leading to denial of service.

#### Severity
Unknown

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

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

---

### Invoking incorrect handling of character references in DOCTYPE nodes in golang.org/x/net/html
[CVE-2026-25681](https://nvd.nist.gov/vuln/detail/CVE-2026-25681) / [GO-2026-5029](https://pkg.go.dev/vuln/GO-2026-5029)

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

#### Details
Parsing arbitrary HTML which is then rendered using Render can result in an unexpected HTML tree. This can be leveraged to execute XSS attacks in applications that attempt to sanitize input HTML before rendering.

#### Severity
Unknown

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

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

---

### Invoking duplicate attributes can cause XSS in golang.org/x/net/html
[CVE-2026-27136](https://nvd.nist.gov/vuln/detail/CVE-2026-27136) / [GO-2026-5030](https://pkg.go.dev/vuln/GO-2026-5030)

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

#### Details
Parsing arbitrary HTML which is then rendered using Render can result in an unexpected HTML tree. This can be leveraged to execute XSS attacks in applications that attempt to sanitize input HTML before rendering.

#### Severity
Unknown

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

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-5030) 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
[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)

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>

---

### 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/1116
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-26 15:37:32 +00:00
Lunny Xiao
94ab020204 ci: mirror release artifacts to Cloudflare R2 (#1114)
Mirrors every release artifact into Cloudflare R2 alongside the existing AWS S3
upload, so both buckets carry the same objects during a parallel period. S3 is
untouched and stays authoritative for now; once R2 is confirmed complete it can
be dropped by deleting the `blobs:` block and pointing this at R2 alone.

### Why `publishers:` and not a second `blobs:` entry

goreleaser's blob pipe authenticates from the global `AWS_*` environment and has
no per-entry credential fields, so two different credential sets (AWS S3 and
Cloudflare R2) cannot coexist in `blobs:`. Custom publishers do support
per-entry `env:`, which is the supported way to isolate the two.

### Why `curl --aws-sigv4` and not `aws`/`rclone`

The CI image `docker.gitea.com/runner-images:ubuntu-latest` ships none of `aws`,
`rclone`, `s3cmd` or `mc`, but does ship curl 8.5 with `--aws-sigv4`. This keeps
the change zero-install. Credentials are fed to curl through a config file on
stdin rather than argv, so they never appear in the process list.

### Behaviour

- Object layout is identical to S3 (`gitea-runner/<version>/<artifact>`), so
  migrating consumers later means changing only the host, not the path.
- Nightly gets R2 too, matching the existing nightly-to-S3 behaviour.
- Missing R2 configuration fails the build. Because custom publishers run as the
  very last step of the publish pipeline, a preflight `--check-config` step runs
  right after checkout so the failure happens before anything is built or
  published rather than after the release already exists.
- Verified against a local MinIO instance (site region `auto`, matching R2):
  successful upload byte-compared after download, plus the missing-variable,
  wrong-credentials, missing-file and bad-argument paths.

### Required repository secrets

These must be configured before the next release run, otherwise the new
preflight step will fail the workflow:

- `R2_ENDPOINT` (full base URL, e.g. `https://<account>.r2.cloudflarestorage.com`)
- `R2_BUCKET`
- `R2_ACCESS_KEY_ID`
- `R2_SECRET_ACCESS_KEY`

### Known, deliberate wart

The publisher runs 109 times for 73 distinct object keys: goreleaser's release
pipe already registers `release.extra_files` as `UploadableFile` artifacts, and
`internal/exec` appends the publisher's own `extra_files` on top with no
de-duplication. It cannot be globbed away because `gobwas/glob` has no
substring-exclusion matcher, so `./**.sha256` cannot be narrowed to exclude
`*.xz.sha256`. It is harmless since PUT is idempotent, and the redundant
`./**.xz` glob is kept on purpose so the publisher declares its own complete
file set instead of implicitly depending on the `release:` block's globs. This
is documented in a comment above the block.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1114
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-07-26 03:26:46 +00:00
silverwind
b4a64b97dd feat: support --platform and --pull in container.options (#1104)
Fixes https://gitea.com/gitea/runner/issues/667
Fixes https://gitea.com/gitea/runner/pulls/1103

`container.options` is parsed with docker/cli's `addFlags`, which omits the flags docker/cli registers on the `create` and `run` commands themselves. Those were rejected as unknown — most visibly `--platform`.

| Flag | Behavior | Why |
| --- | --- | --- |
| `--platform` | overrides the runner-wide container architecture | the only way to pick an image architecture per job, e.g. across a matrix |
| `--pull always\|missing\|never` | `always` forces a pull, `never` skips it | `force_pull` is runner-wide config today, with no per-job control |

Both are resolved in `NewContainer` because the image pull runs before the container is created and the two have to agree.

`--name`, `--quiet` and `--disable-content-trust` are now accepted and ignored, and `--use-api-socket` is rejected pointing at `container.docker_host`, so a valid `docker create` line no longer fails outright.

<sub>Written by Claude (Opus 4.8).</sub>

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1104
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-24 16:42:30 +00:00
bircni
26f9fb12af fix: clean service containers after failed job setup (#1066)
Fixes https://gitea.com/gitea/runner/issues/659

Docker teardown was chained with `Then`, which stops on the previous step's error and on a cancelled context, so the first failure orphaned everything downstream. A volume that is still in use, or a flaky daemon, left the service containers and the job network behind — matching the reports in the issue, where the leaks show up after failed or overloaded jobs.

Cleanup is now straight-line and best-effort: every step runs regardless of what failed before it, and the errors are joined. Two things follow from that:

- Service containers are removed before the job volumes, since a service can hold one via `--volumes-from` in its options. The network stays last, once every container has detached.
- Only job container and volume errors are returned. Service and network errors are logged, as before, because `stopJobContainer()` also runs as the pre-flight step of job start, where a network cleanup error must not abort the job.

The nil check on `rc.JobContainer` is kept, but it is not the leak reporters are hitting: `container.NewContainer` never returns nil in the docker build, so cleanup with no job container is only reachable in the `WITHOUT_DOCKER` stub.

Addresses https://gitea.com/gitea/runner/pulls/1066#issuecomment-1239073.

Not covered here: the `buildx_buildkit_*` volumes reported in the issue are created by buildx inside the job, not by the runner, so no runner-side teardown removes them.

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1066
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-07-24 16:36:09 +00:00
Zettat123
c9c4957e38 feat: support reading cache.external_secret from a file (#1100)
This PR adds a new `cache.external_secret_file` config, which points at a file holding the secret. So the secret can come from a mounted Kubernetes/Docker secret while the rest of the config stays plain text.

```yaml
cache:
  external_server: "http://cache-host:8088/"
  external_secret_file: /path/to/cache_external_secret
```

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1100
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-23 06:26:10 +00:00
Renovate Bot
b1a02cdd5d chore(deps): update docker docker tag to v29.6.2 (#1101)
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-23 06:19:27 +00:00
Renovate Bot
0fd8602ac3 chore(deps): update actions/setup-go action to v7 (#1102)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-go](https://github.com/actions/setup-go) | action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>actions/setup-go (actions/setup-go)</summary>

### [`v7.0.0`](https://github.com/actions/setup-go/releases/tag/v7.0.0)

[Compare Source](https://github.com/actions/setup-go/compare/v7.0.0...v7.0.0)

##### What's Changed

- Migrate to ESM and upgrade dependencies by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;763](https://github.com/actions/setup-go/pull/763)
- chore(deps): bump [@&#8203;actions/cache](https://github.com/actions/cache) to 6.2.0 by [@&#8203;philip-gai](https://github.com/philip-gai) in [#&#8203;771](https://github.com/actions/setup-go/pull/771)

##### New Contributors

- [@&#8203;philip-gai](https://github.com/philip-gai) made their first contribution in [#&#8203;771](https://github.com/actions/setup-go/pull/771)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v7.0.0>

### [`v7`](https://github.com/actions/setup-go/compare/v6.5.0...v7.0.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.5.0...v7.0.0)

### [`v6.5.0`](https://github.com/actions/setup-go/releases/tag/v6.5.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.4.0...v6.5.0)

##### What's Changed

##### Dependency update

- Upgrade actions dependencies by [@&#8203;priyagupta108](https://github.com/priyagupta108) with [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;744](https://github.com/actions/setup-go/pull/744)
- Upgrade [@&#8203;types/node](https://github.com/types/node) and typescript-eslint dependencies to resolve npm audit findings by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;755](https://github.com/actions/setup-go/pull/755)
- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to 5.1.0, log cache write denied by [@&#8203;jasongin](https://github.com/jasongin) in [#&#8203;758](https://github.com/actions/setup-go/pull/758)
- Upgrade version to 6.5.0 in package.json and package-lock.json by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;762](https://github.com/actions/setup-go/pull/762)

##### New Contributors

- [@&#8203;priyagupta108](https://github.com/priyagupta108) with [@&#8203;Copilot](https://github.com/Copilot) made their first contribution in [#&#8203;744](https://github.com/actions/setup-go/pull/744)
- [@&#8203;jasongin](https://github.com/jasongin) made their first contribution in [#&#8203;758](https://github.com/actions/setup-go/pull/758)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.5.0>

### [`v6.4.0`](https://github.com/actions/setup-go/releases/tag/v6.4.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.3.0...v6.4.0)

##### What's Changed

##### Enhancement

- Add go-download-base-url input for custom Go distributions by [@&#8203;gdams](https://github.com/gdams) in [#&#8203;721](https://github.com/actions/setup-go/pull/721)

##### Dependency update

- Upgrade minimatch from 3.1.2 to 3.1.5 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;727](https://github.com/actions/setup-go/pull/727)

##### Documentation update

- Rearrange README.md, add advanced-usage.md by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;724](https://github.com/actions/setup-go/pull/724)
- Fix Microsoft build of Go link by [@&#8203;gdams](https://github.com/gdams) in [#&#8203;734](https://github.com/actions/setup-go/pull/734)

##### New Contributors

- [@&#8203;gdams](https://github.com/gdams) made their first contribution in [#&#8203;721](https://github.com/actions/setup-go/pull/721)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.4.0>

### [`v6.3.0`](https://github.com/actions/setup-go/releases/tag/v6.3.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.2.0...v6.3.0)

##### What's Changed

- Update default Go module caching to use go.mod by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;705](https://github.com/actions/setup-go/pull/705)
- Fix golang download url to go.dev by [@&#8203;178inaba](https://github.com/178inaba) in [#&#8203;469](https://github.com/actions/setup-go/pull/469)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.3.0>

### [`v6.2.0`](https://github.com/actions/setup-go/releases/tag/v6.2.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.1.0...v6.2.0)

##### What's Changed

##### Enhancements

- Example for restore-only cache in documentation  by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;696](https://github.com/actions/setup-go/pull/696)
- Update Node.js version in action.yml by [@&#8203;ccoVeille](https://github.com/ccoVeille) in [#&#8203;691](https://github.com/actions/setup-go/pull/691)
- Documentation update of actions/checkout by [@&#8203;deining](https://github.com/deining) in [#&#8203;683](https://github.com/actions/setup-go/pull/683)

##### Dependency updates

- Upgrade js-yaml from 3.14.1 to 3.14.2 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;682](https://github.com/actions/setup-go/pull/682)
- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to v5 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;695](https://github.com/actions/setup-go/pull/695)
- Upgrade actions/checkout from 5 to 6 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;686](https://github.com/actions/setup-go/pull/686)
- Upgrade qs from 6.14.0 to 6.14.1 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;703](https://github.com/actions/setup-go/pull/703)

##### New Contributors

- [@&#8203;ccoVeille](https://github.com/ccoVeille) made their first contribution in [#&#8203;691](https://github.com/actions/setup-go/pull/691)
- [@&#8203;deining](https://github.com/deining) made their first contribution in [#&#8203;683](https://github.com/actions/setup-go/pull/683)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.2.0>

### [`v6.1.0`](https://github.com/actions/setup-go/releases/tag/v6.1.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6...v6.1.0)

##### What's Changed

##### Enhancements

- Fall back to downloading from go.dev/dl instead of storage.googleapis.com/golang by [@&#8203;nicholasngai](https://github.com/nicholasngai) in [#&#8203;665](https://github.com/actions/setup-go/pull/665)
- Add support for .tool-versions file and update workflow by [@&#8203;priya-kinthali](https://github.com/priya-kinthali) in [#&#8203;673](https://github.com/actions/setup-go/pull/673)
- Add comprehensive breaking changes documentation for v6 by [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) in [#&#8203;674](https://github.com/actions/setup-go/pull/674)

##### Dependency updates

- Upgrade eslint-config-prettier from 10.0.1 to 10.1.8 and document breaking changes in v6 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;617](https://github.com/actions/setup-go/pull/617)
- Upgrade actions/publish-action from 0.3.0 to 0.4.0 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;641](https://github.com/actions/setup-go/pull/641)
- Upgrade semver and [@&#8203;types/semver](https://github.com/types/semver) by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;652](https://github.com/actions/setup-go/pull/652)

##### New Contributors

- [@&#8203;nicholasngai](https://github.com/nicholasngai) made their first contribution in [#&#8203;665](https://github.com/actions/setup-go/pull/665)
- [@&#8203;priya-kinthali](https://github.com/priya-kinthali) made their first contribution in [#&#8203;673](https://github.com/actions/setup-go/pull/673)
- [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) made their first contribution in [#&#8203;674](https://github.com/actions/setup-go/pull/674)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.1.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/1102
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-23 05:41:55 +00:00
silverwind
6133d64270 fix: repair free-disk-space build on FreeBSD (#1098)
`Statfs_t.Bavail` is unsigned on Linux but signed on FreeBSD, so `Bavail * uint64(Bsize)` in `internal/app/run/disk_unix.go` fails to compile for the freebsd targets goreleaser cross-builds, breaking the nightly release (introduced in https://gitea.com/gitea/runner/pulls/1090). The `checks` workflow only builds for the host, so it never cross-compiles freebsd and stayed green.

Casting both operands to `uint64` makes the arithmetic signedness-agnostic across all unix variants.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1098
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-22 19:01:06 +00:00
bircni
c43cbe87ca feat: add runner health admission checks (#1090)
Opt-in local task-admission checks under a `health_check` config section (disabled by default):

- pause new task fetching when free disk space on the workspace volume is below the configured minimum
- optional executable health-check script — a non-zero exit, timeout, or start failure marks the runner unavailable
- checks run only while the runner is idle; the last result is reused while a job is active, and polling resumes automatically on recovery
- `/readyz` reports task-admission readiness (reusing the poll loop's last check); `/healthz` stays a process-liveness endpoint

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1090
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-07-22 15:10:45 +00:00
bircni
7bec310002 fix: stop host-mode jobs from leaking processes on Windows (#1080)
## Problem

On host-mode Windows runners, a job can leave processes running after it finishes,
and those leftovers hold file handles that block deletion of the workspace.

Today a step's tree is only torn down when the step is *cancelled*
(`process.Killer`). A step that completes leaves whatever it spawned alive, and
the existing workspace scan in `terminateRunningProcesses` misses two shapes of
leftover: orphans whose parent already exited (no tree to walk, and their
executable often lives outside the workspace), and processes that merely *run in*
the workspace but reference no path from it — `Win32_Process` exposes no working
directory, so the scan cannot match them.

## Solution

Two additions in `internal/pkg/process`, both no-ops outside Windows:

- **`process.Group`** — a job-scoped Windows Job Object created with
  `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Step processes are assigned to it before
  they get their own `Killer`, so the step's job nests inside the job's:
  cancellation still kills exactly the step's tree, while `Remove` closing the
  group makes the kernel terminate everything still assigned, whatever its
  parentage. The kernel also drops the handle when the runner exits, so a crashed
  runner cannot strand processes.

- **`process.KillProcessesWithCWDUnder`** — a best-effort net for processes that
  never joined the job (started via a service or scheduled task). It reads each
  process's working directory from its PEB and terminates those under a workspace
  dir. Processes it cannot open are skipped.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1080
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-07-22 15:04:07 +00:00
bircni
8af385d147 enhance: report a GitHub-style "Set up job" section (#1089)
Reshapes the job log's "Set up job" section to mirror `actions/runner`:

- runner name/version, then `Runner Information` (labels, task, job, repository, event) and `Operating System` groups
- every required action downloaded up front under `Prepare all required actions`, each as `Download action repository '<action>@<ref>' (SHA:<sha>)`
- `Complete job name` closes the section

Downloading up front is the one behavioral change: the same set was already fetched during the pre stage regardless of a step's `if`, now just before the first pre step, so a download failure is reported against the job rather than a step.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1089
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-07-22 14:58:23 +00:00
Renovate Bot
46f22c78d2 fix(deps): update module github.com/mattn/go-isatty to v0.0.23 (#1097)
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-22 14:51:10 +00:00
Renovate Bot
068afc3996 fix(deps): update module github.com/docker/cli to v29.6.2+incompatible (#1096)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/docker/cli](https://github.com/docker/cli) | `v29.6.1+incompatible` → `v29.6.2+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.6.2+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.6.1+incompatible/v29.6.2+incompatible?slim=true) |

---

### Release Notes

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

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

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/1096
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-22 14:05:48 +00:00
silverwind
0e8896c52a fix: classify a cancelled step as an interruption, not a failure (#1095)
`reportStepError` reported every step error as FAILURE, including a `context.Canceled` from a docker file-command read cancelled at job finalization — non-deterministic red CI. Classify `context.Canceled` as an interruption instead (deferring to the job context), so a genuine cancel reports cancelled and a stray teardown cancellation is ignored, never a failure.

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1095
Reviewed-by: bircni <bircni@icloud.com>
2026-07-21 11:16:56 +00:00
silverwind
89467c9dd0 fix: stop racing the daemon when removing containers (#1093)
Containers set `HostConfig.AutoRemove` but act also removes them explicitly, so the two removers race and the loser logs a 409 `removal of container X is already in progress` — seen at the end of nearly every `uses: docker://` step.

The explicit remove is redundant for `docker://` steps and docker actions (`Start(true)` already awaited exit), so it's skipped. Job and service containers keep both removers — their `sleep` entrypoint needs `AutoRemove` as a fallback reaper — so there the race is inherent and `remove()` now treats `NotFound` and `Conflict` as success.

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1093
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-21 11:03:15 +00:00
Renovate Bot
0c08b0f2da chore(deps): update actions/setup-node action to v7 (#1094)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-node](https://github.com/actions/setup-node) | action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>actions/setup-node (actions/setup-node)</summary>

### [`v7.0.0`](https://github.com/actions/setup-node/releases/tag/v7.0.0)

[Compare Source](https://github.com/actions/setup-node/compare/v7.0.0...v7.0.0)

#### What's Changed

##### Enhancements:

- Add cache-primary-key and cache-matched-key as outputs by [@&#8203;gowridurgad](https://github.com/gowridurgad) in [#&#8203;1577](https://github.com/actions/setup-node/pull/1577)
- Migrate to ESM and upgrade dependencies by [@&#8203;gowridurgad](https://github.com/gowridurgad) in [#&#8203;1574](https://github.com/actions/setup-node/pull/1574)

##### Bug fixes:

- Remove dummy NODE\_AUTH\_TOKEN export by [@&#8203;gowridurgad](https://github.com/gowridurgad) in [#&#8203;1558](https://github.com/actions/setup-node/pull/1558)
- Only use `mirrorToken` in `getManifest` if it's provided by [@&#8203;deiga](https://github.com/deiga) in [#&#8203;1548](https://github.com/actions/setup-node/pull/1548)

##### Documentation updates:

- Add documentation for publishing to npm with Trusted Publisher (OIDC) by [@&#8203;chiranjib-swain](https://github.com/chiranjib-swain) in [#&#8203;1536](https://github.com/actions/setup-node/pull/1536)
- docs: Update restore-only cache documentation by [@&#8203;priya-kinthali](https://github.com/priya-kinthali) in [#&#8203;1550](https://github.com/actions/setup-node/pull/1550)
- docs: Update caching recommendations to mitigate cache poisoning risks by [@&#8203;chiranjib-swain](https://github.com/chiranjib-swain) in [#&#8203;1567](https://github.com/actions/setup-node/pull/1567)

##### Dependency update:

- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to 5.1.0, log cache write denied by [@&#8203;jasongin](https://github.com/jasongin) in [#&#8203;1569](https://github.com/actions/setup-node/pull/1569)

#### New Contributors

- [@&#8203;chiranjib-swain](https://github.com/chiranjib-swain) made their first contribution in [#&#8203;1536](https://github.com/actions/setup-node/pull/1536)
- [@&#8203;deiga](https://github.com/deiga) made their first contribution in [#&#8203;1548](https://github.com/actions/setup-node/pull/1548)
- [@&#8203;jasongin](https://github.com/jasongin) made their first contribution in [#&#8203;1569](https://github.com/actions/setup-node/pull/1569)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6...v7.0.0>

### [`v7`](https://github.com/actions/setup-node/compare/v6.5.0...v7.0.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6.5.0...v7.0.0)

### [`v6.5.0`](https://github.com/actions/setup-node/releases/tag/v6.5.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0)

#### What's Changed

- Update [@&#8203;actions/cache](https://github.com/actions/cache) to 5.1.0 and add security overrides for undici and fast-xml-parser by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;1579](https://github.com/actions/setup-node/pull/1579)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0>

### [`v6.4.0`](https://github.com/actions/setup-node/releases/tag/v6.4.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6.3.0...v6.4.0)

#### What's Changed

##### Dependency updates:

- Upgrade [@&#8203;actions](https://github.com/actions) dependencies by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;1525](https://github.com/actions/setup-node/pull/1525)
- Update Node.js versions in versions.yml and bump package to v6.4.0  by [@&#8203;priya-kinthali](https://github.com/priya-kinthali) in [#&#8203;1533](https://github.com/actions/setup-node/pull/1533)

#### New Contributors

- [@&#8203;Copilot](https://github.com/Copilot) made their first contribution in [#&#8203;1525](https://github.com/actions/setup-node/pull/1525)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6...v6.4.0>

### [`v6.3.0`](https://github.com/actions/setup-node/releases/tag/v6.3.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6.2.0...v6.3.0)

#### What's Changed

##### Enhancements:

- Support parsing `devEngines` field by [@&#8203;susnux](https://github.com/susnux) in [#&#8203;1283](https://github.com/actions/setup-node/pull/1283)

> When using node-version-file: package.json, setup-node now prefers devEngines.runtime over engines.node.

##### Dependency updates:

- Fix npm audit issues by [@&#8203;gowridurgad](https://github.com/gowridurgad) in [#&#8203;1491](https://github.com/actions/setup-node/pull/1491)
- Replace uuid with crypto.randomUUID() by [@&#8203;trivikr](https://github.com/trivikr) in [#&#8203;1378](https://github.com/actions/setup-node/pull/1378)
- Upgrade minimatch from 3.1.2 to 3.1.5 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1498](https://github.com/actions/setup-node/pull/1498)

##### Bug fixes:

- Remove hardcoded bearer for mirror-url [@&#8203;marco-ippolito](https://github.com/marco-ippolito) in [#&#8203;1467](https://github.com/actions/setup-node/pull/1467)
- Scope test lockfiles by package manager and update cache tests by [@&#8203;gowridurgad](https://github.com/gowridurgad) in [#&#8203;1495](https://github.com/actions/setup-node/pull/1495)

#### New Contributors

- [@&#8203;susnux](https://github.com/susnux) made their first contribution in [#&#8203;1283](https://github.com/actions/setup-node/pull/1283)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6...v6.3.0>

### [`v6.2.0`](https://github.com/actions/setup-node/releases/tag/v6.2.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6.1.0...v6.2.0)

#### What's Changed

##### Documentation

- Documentation update related to absence of Lockfile by [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) in [#&#8203;1454](https://github.com/actions/setup-node/pull/1454)
- Correct mirror option typos by [@&#8203;MikeMcC399](https://github.com/MikeMcC399) in [#&#8203;1442](https://github.com/actions/setup-node/pull/1442)
- Readme update on checkout version v6 by [@&#8203;deining](https://github.com/deining) in [#&#8203;1446](https://github.com/actions/setup-node/pull/1446)
- Readme typo fixes [@&#8203;munyari](https://github.com/munyari) in [#&#8203;1226](https://github.com/actions/setup-node/pull/1226)
- Advanced document update on checkout version v6 by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y)  in [#&#8203;1468](https://github.com/actions/setup-node/pull/1468)

##### Dependency updates:

- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to v5.0.1 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;1449](https://github.com/actions/setup-node/pull/1449)

#### New Contributors

- [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) made their first contribution in [#&#8203;1454](https://github.com/actions/setup-node/pull/1454)
- [@&#8203;MikeMcC399](https://github.com/MikeMcC399) made their first contribution in [#&#8203;1442](https://github.com/actions/setup-node/pull/1442)
- [@&#8203;deining](https://github.com/deining) made their first contribution in [#&#8203;1446](https://github.com/actions/setup-node/pull/1446)
- [@&#8203;munyari](https://github.com/munyari) made their first contribution in [#&#8203;1226](https://github.com/actions/setup-node/pull/1226)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6...v6.2.0>

### [`v6.1.0`](https://github.com/actions/setup-node/releases/tag/v6.1.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6...v6.1.0)

#### What's Changed

##### Enhancement:

- Remove always-auth configuration handling by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;1436](https://github.com/actions/setup-node/pull/1436)

##### Dependency updates:

- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) from 4.0.3 to 4.1.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1384](https://github.com/actions/setup-node/pull/1384)
- Upgrade actions/checkout from 5 to 6 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1439](https://github.com/actions/setup-node/pull/1439)
- Upgrade js-yaml from 3.14.1 to 3.14.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1435](https://github.com/actions/setup-node/pull/1435)

##### Documentation update:

- Add example for restore-only cache in documentation by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;1419](https://github.com/actions/setup-node/pull/1419)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6...v6.1.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->Reviewed-on: https://gitea.com/gitea/runner/pulls/1094
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-21 07:15:33 +00:00
bircni
aa7a29a157 fix: guard status-check functions against a nil job context (#1092)
The `cancelled()`, `success()` and `failure()` expression functions dereferenced `Job.Status` unconditionally, so a nil `Job` context panicked the interpreter — which is why Gitea currently hands the runner a non-nil (empty) `JobContext` as a workaround. This routes all three through a `jobStatus()` helper that treats a nil `Job` as an empty status, keeping existing behaviour identical while removing the panic. Includes a regression test that panics on the old code and passes with the fix.

Related: https://github.com/go-gitea/gitea/pull/38495

Reviewed-on: https://gitea.com/gitea/runner/pulls/1092
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-07-16 21:33:26 +00:00
Renovate Bot
ad967330a8 fix(deps): update module golang.org/x/text to v0.40.0 (#1091)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) | [`v0.37.0` → `v0.40.0`](https://cs.opensource.google/go/x/text/+/refs/tags/v0.37.0...refs/tags/v0.40.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2ftext/v0.40.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2ftext/v0.37.0/v0.40.0?slim=true) |

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->Reviewed-on: https://gitea.com/gitea/runner/pulls/1091
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-16 09:22:33 +00:00
bircni
60177008a5 fix: ignore blank lines and decode UTF-16 in the runner env files (#1084)
Fixes #496
Fixes #552

Reviewed-on: https://gitea.com/gitea/runner/pulls/1084
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-07-15 19:45:50 +00:00
bircni
58c5eb8d21 feat: honour GITEA_RUNNER_LABELS on daemon start and accept labels containing a colon (#1085)
Fixes #648
Fixes #656
Fixes #664

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1085
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-07-15 16:58:07 +00:00
bircni
d6882b3df5 docs: explain labels, the docker image cache volume, and the dind-rootless UID (#1086)
Fixes #106
Fixes #570
Fixes #627

Reviewed-on: https://gitea.com/gitea/runner/pulls/1086
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-15 06:34:32 +00:00
bircni
7e7e3ef1a6 fix: stop service containers from clobbering the job container's credentials (#1083)
Fixes #835
Fixes #643

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1083
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-15 06:15:50 +00:00
Nicolas
16357a34b2 fix: accept natively typed boolean workflow inputs (#1087)
Gitea 1.27 resolves `workflow_call` inputs server-side and sends natively typed JSON values in `github.event.inputs`, so a `type: boolean` input now arrives as a real JSON boolean. The runner coerced booleans by comparing the `any` value against the string `"true"`, which a native bool never matches, so every boolean input evaluated to `false` — including when the callee relied on its default, since the server pre-fills defaults into the event payload and the string fallback is never reached. This accepts a native bool and keeps the string comparison as a fallback, since `workflow_dispatch` inputs are still strings and YAML defaults decode to strings; servers before 1.27 never put a native bool in the payload, so they take the exact same code path as before.

The same coercion is applied to `setupWorkflowInputs` (locally-called reusable workflows, `uses: ./.gitea/workflows/x.yml`), where a `type: boolean` input was previously a native bool when passed as `with: { flag: true }` but a string when interpolated or taken from `default:`. It is now always a bool, matching GitHub, whose `inputs` context "preserves Boolean values as Booleans instead of converting them to strings". **This is potentially breaking**: `inputs.flag == 'true'` now evaluates to `false` and must become `inputs.flag == true`. That pattern is already false on GitHub (a bool compared to a string coerces to `1 == NaN`), but it works on Gitea today, so I am happy to split this hunk into its own PR if you would rather keep this one backport-safe.

Fixes #1082

Reviewed-on: https://gitea.com/gitea/runner/pulls/1087
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: Nicolas <bircni@icloud.com>
2026-07-14 20:16:19 +00:00
Nicolas
d53538ac38 fix: drop action outputs whose value exceeds the size limit (#1070)
`SetOutputs` logged "ignore output because the value is too long" for values
larger than 1 MiB but then fell through and stored the value anyway, sending
it upstream via `UpdateTask`. The key-too-long branch directly above correctly
skips oversized keys with `continue`; this adds the same `continue` to the
value branch so the size guard is actually enforced and the log message
matches the behavior.

Adds regression coverage in `TestReporter_SetOutputs` for an oversized value
(dropped) and a value at exactly the 1 MiB limit (retained).

---------

Co-authored-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1070
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-07-14 14:28:57 +00:00
Renovate Bot
554b3b7671 fix(deps): update module golang.org/x/term to v0.45.0 (#1081)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) | [`v0.44.0` → `v0.45.0`](https://cs.opensource.google/go/x/term/+/refs/tags/v0.44.0...refs/tags/v0.45.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fterm/v0.45.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fterm/v0.44.0/v0.45.0?slim=true) |

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->Reviewed-on: https://gitea.com/gitea/runner/pulls/1081
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-14 11:19:52 +00:00
Nicolas
65756d60b3 fix: Minor fixes (#1075)
A batch of small, self-contained fixes and docs/example additions.

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

In addition to fixing merge conflicts and the `fmt.Errorf` issue from the previous version, I've also added a set of tests to cover some basic usage of `initInputs`

---------

Co-authored-by: Félix Baylac Jacqué <felix@alternativebit.fr>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1076
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: h7x4 <h7x4@nani.wtf>
2026-07-11 17:02:11 +00:00
silverwind
1d74ae636a fix: skip service containers with an empty image (#1074)
GitHub Actions skips services whose image evaluates to an empty string, enabling conditional services via expressions like `image: ${{ matrix.image || '' }}`. Here such a service failed the job at `docker create`. Skip them before container creation, using GitHub's log message verbatim, and add a regression test.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1074
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-09 15:08:59 +00:00
Renovate Bot
b12d02c25f fix(deps): update module golang.org/x/sys to v0.47.0 (#1073)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) | [`v0.46.0` → `v0.47.0`](https://cs.opensource.google/go/x/sys/+/refs/tags/v0.46.0...refs/tags/v0.47.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fsys/v0.47.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fsys/v0.46.0/v0.47.0?slim=true) |

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->Reviewed-on: https://gitea.com/gitea/runner/pulls/1073
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-09 09:14:35 +00:00
bircni
f2e0cf9131 docs: clarify cache reachability for dockerized runners (#1069)
Refs https://gitea.com/gitea/runner/issues/155

The remaining failure mode described in the issue comments is the job container timing out when it tries to reach ACTIONS_CACHE_URL. The generated config now explains that cache.host/cache.port must be reachable from job containers, and calls out dockerized runners with auto-created per-job networks as a case that may need a fixed published cache endpoint or a shared Docker network

Co-authored-by: bircni <bircni@icloud.com>
2026-07-04 00:06:26 +00:00
silverwind
0ee4643d4a fix: install nftables in dind images to silence nft cleanup errors (#1064)
Fixes: https://gitea.com/gitea/runner/issues/1061
Related: https://github.com/moby/moby/pull/52886

Docker 29.6.0 changed `dockerd` to shell out to the external `nft` binary for firewall-rule cleanup instead of linking `libnftables` (release note: *"Mitigate a crash in libnftables when using nftables as the firewall backend by changing the default build option to execute the `nft` command instead."*). The upstream `docker:dind` image only ships `iptables`/`ip6tables`, so `dockerd` logs `failed to find nft tool` on startup and shutdown.

Networking is unaffected (default firewall backend is still iptables), but the errors are noisy. Install `nftables` in both dind variants to provide the binary.

*PR authored by Claude (Opus 4.8).*Reviewed-on: https://gitea.com/gitea/runner/pulls/1064
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-03 08:23:12 +00:00
Renovate Bot
e774003c18 chore(deps): update docker docker tag to v29.6.1 (#1063)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| docker | stage | patch | `29.6.0-dind-rootless` → `29.6.1-dind-rootless` |
| docker | stage | patch | `29.6.0-dind` → `29.6.1-dind` |

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-02 08:22:37 +00:00
Nicolas
eeb479ea89 fix: prevent exponential growth of RunContext masks in composite actions (#1059)
A composite action's `RunContext` is **seeded with its parent's `Masks` slice** in `newCompositeRunContext`. After the composite finished running, the *entire* `compositeRC.Masks` (the seeded parent masks plus any added during the run) was appended straight back into the parent:

```go
rc.Masks = append(rc.Masks, compositeRC.Masks...)
```

Because the composite already contained the parent's masks, every composite action roughly **doubled** the parent's `Masks` length. With many nested/repeated composite actions this grows exponentially:

```
before=1152 after=2304 compositeLen=1152
before=2304 after=4608 compositeLen=2304
before=4608 after=9216 compositeLen=4608
...
```

### Impact

The bloated `Masks` slice made per-log-line secret redaction (`strings.makeGenericReplacer`, rebuilt per line since #1001) extremely slow. Log writes fell behind enough to keep subprocess pipes open after the process had already exited, surfacing as seemingly random CI failures — most often in later steps of mask-heavy workflows:

```
Error: exec: WaitDelay expired before I/O complete
```

The split of `cmd.Run()` into `cmd.Start()` + `cmd.Wait()` (#883) is what turned this long-standing latency into a hard failure.

### Fix

Only append masks that aren't already present in the parent, via a small `appendUniqueMasks` helper. This keeps the mask count bounded by the number of *distinct* secrets instead of growing exponentially.

I kept `Masks` as a `[]string` rather than switching to a set type, since it's passed by pointer to loggers across `logger.go`, `run_context.go`, and `runner.go` — a type change would be a much wider, riskier refactor. The `O(n·m)` dedup is harmless precisely because `n` now stays small.

Fixes #1054Reviewed-on: https://gitea.com/gitea/runner/pulls/1059
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-01 18:50:34 +00:00
Renovate Bot
eba33e178d fix(deps): update module github.com/docker/cli to v29.6.1+incompatible (#1060)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/docker/cli](https://github.com/docker/cli) | `v29.6.0+incompatible` → `v29.6.1+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.6.1+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.6.0+incompatible/v29.6.1+incompatible?slim=true) |

---

### Release Notes

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

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

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1060
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-01 18:44:29 +00:00
bircni
3396021e0f test: Enhance Coverage + CI (#1055)
Reviewed-on: https://gitea.com/gitea/runner/pulls/1055
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-01 03:26:42 +00:00
bircni
745b0ab6e4 fix: attach task token when cloning actions from self-hosted instance on a different host (#1056)
When `DEFAULT_ACTIONS_URL=self`, action clone URLs (`uses: owner/repo@ref`) are
built from the Gitea **AppURL** (`gitea_default_actions_url`), but
`shouldCloneURLUseToken` compared the clone URL host only against the runner's
**registered address** (`GitHubInstance`).

When the runner registers with a different hostname than AppURL — same instance,
different DNS (e.g. `gitea.local` vs `gitea.my-nas.lan`, internal vs external) —
the strict `u1.Host == u2.Host` check returns false, so the task token is **not**
attached and the action clone goes out anonymously. Against an instance with
`REQUIRE_SIGNIN_VIEW=true` this fails with:

```
Unable to clone https://gitea.example/owner/action refs/heads/v1: authentication required
```

The current workaround is to make the runner's registered host exactly match
`AppURL`. This PR removes the need for that.

Refs: https://github.com/go-gitea/gitea/issues/27933

## Change

- `shouldCloneURLUseToken` now trusts the clone URL when its host matches **either**
  the registered instance (`GitHubInstance`) **or** the self-hosted default-actions
  instance (`DefaultActionInstance`). Embedded basic auth is still rejected, and the
  empty-host cases are unchanged.
- A new `Config.DefaultActionInstanceIsSelfHosted` flag gates the second candidate.
  It is set in the daemon layer (`run/runner.go`, `exec.go`), where `github.com` and
  a configured `GithubMirror` are distinguishable, so the token is **never** attached
  for off-instance hosts.Reviewed-on: https://gitea.com/gitea/runner/pulls/1056
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-06-30 16:18:12 +00:00
110 changed files with 6046 additions and 356 deletions

View File

@@ -18,8 +18,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- run: make lint-pr-title

View File

@@ -17,14 +17,26 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
# Custom publishers (the R2 mirror below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release
# has already been created and every artifact already uploaded
# to S3. Fail here instead, before anything is built or
# published, if the R2 secrets are missing.
- name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: "go.mod"
- name: goreleaser
uses: goreleaser/goreleaser-action@v7
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
distribution: goreleaser-pro
args: release --nightly
@@ -35,6 +47,10 @@ jobs:
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_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
GORELEASER_FORCE_TOKEN: "gitea"
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -43,27 +59,33 @@ jobs:
strategy:
matrix:
variant:
# The basic image is built from source and can target any arch the
# toolchain supports. The dind variants are limited to the arches the
# docker:dind base image publishes.
- target: basic
tag_suffix: ""
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
- target: dind
tag_suffix: "-dind"
platforms: linux/amd64,linux/arm64
- target: dind-rootless
tag_suffix: "-dind-rootless"
platforms: linux/amd64,linux/arm64
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -77,14 +99,12 @@ jobs:
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: ./Dockerfile
target: ${{ matrix.variant.target }}
platforms: |
linux/amd64
linux/arm64
platforms: ${{ matrix.variant.platforms }}
push: true
tags: |
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}

View File

@@ -9,21 +9,33 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # all history for all branches and tags
- uses: actions/setup-go@v6
# Custom publishers (the R2 mirror below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release
# has already been created and every artifact already uploaded
# to S3. Fail here instead, before anything is built or
# published, if the R2 secrets are missing.
- name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: "go.mod"
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.PASSPHRASE }}
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
- name: goreleaser
uses: goreleaser/goreleaser-action@v7
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
distribution: goreleaser-pro
args: release
@@ -34,6 +46,10 @@ jobs:
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_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
GORELEASER_FORCE_TOKEN: "gitea"
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
@@ -42,12 +58,18 @@ jobs:
strategy:
matrix:
variant:
# The basic image is built from source and can target any arch the
# toolchain supports. The dind variants are limited to the arches the
# docker:dind base image publishes.
- target: basic
tag_suffix: ""
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
- target: dind
tag_suffix: "-dind"
platforms: linux/amd64,linux/arm64
- target: dind-rootless
tag_suffix: "-dind-rootless"
platforms: linux/amd64,linux/arm64
container:
image: catthehacker/ubuntu:act-latest
env:
@@ -55,25 +77,25 @@ jobs:
DOCKER_LATEST: latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: "Docker meta"
id: docker_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: |
${{ env.DOCKER_ORG }}/runner
@@ -86,14 +108,12 @@ jobs:
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: ./Dockerfile
target: ${{ matrix.variant.target }}
platforms: |
linux/amd64
linux/arm64
platforms: ${{ matrix.variant.platforms }}
push: true
tags: ${{ steps.docker_meta.outputs.tags }}
build-args: |

View File

@@ -17,8 +17,8 @@ jobs:
# to ~/.docker with the stale credentials.
DOCKER_CONFIG: /tmp/docker-noauth
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: 'go.mod'
- name: prepare anonymous docker config
@@ -42,3 +42,7 @@ jobs:
# after `make test` so the images it needs are already present on the host daemon.
- name: test against dind image
run: make test-dind
- name: coverage report
run: |
make coverage-report
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"

2
.gitignore vendored
View File

@@ -3,6 +3,7 @@
!/act/runner/testdata/secrets/.env
.runner
coverage.txt
.tmp/
/config.yaml
# Jetbrains
@@ -12,3 +13,4 @@ coverage.txt
__debug_bin
# gorelease binary folder
/dist
.DS_Store

View File

@@ -93,6 +93,37 @@ blobs:
- 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
# goreleaser's release pipe already registers `release.extra_files`
# as UploadableFile artifacts, and `internal/exec`'s filterArtifacts
# appends this block's own extra_files with no de-duplication. It
# can't be globbed away, since gobwas/glob (via goreleaser/fileglob)
# has no substring-exclusion matcher. It's harmless: PUT is
# idempotent, and the `./**.xz` glob below is kept deliberately so
# this publisher declares its own complete file set rather than
# implicitly depending on the `release:` block's globs.
publishers:
- name: cloudflare-r2
checksum: true
extra_files:
- glob: ./**.xz
- glob: ./**.sha256
cmd: sh scripts/upload-r2.sh {{ abs .ArtifactPath }} gitea-runner/{{ .Version }}/{{ .ArtifactName }}
env:
- R2_ENDPOINT={{ index .Env "R2_ENDPOINT" }}
- R2_BUCKET={{ index .Env "R2_BUCKET" }}
- R2_ACCESS_KEY_ID={{ index .Env "R2_ACCESS_KEY_ID" }}
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
archives:
- format: binary
name_template: "{{ .Binary }}"

View File

@@ -17,14 +17,14 @@ RUN make clean && make build
### DIND VARIANT
#
#
FROM docker:29.6.0-dind AS dind
FROM docker:29.6.2-dind AS dind
ARG VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}"
RUN apk add --no-cache s6 bash git tzdata
RUN apk add --no-cache s6 bash git tzdata nftables
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY scripts/run.sh /usr/local/bin/run.sh
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
### DIND-ROOTLESS VARIANT
#
#
FROM docker:29.6.0-dind-rootless AS dind-rootless
FROM docker:29.6.2-dind-rootless AS dind-rootless
ARG VERSION=dev
@@ -45,7 +45,7 @@ LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}"
USER root
RUN apk add --no-cache s6 bash git tzdata
RUN apk add --no-cache s6 bash git tzdata nftables
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY scripts/run.sh /usr/local/bin/run.sh

View File

@@ -151,6 +151,12 @@ tidy-check: tidy
test: fmt-check security-check ## 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
.PHONY: coverage-report
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
@mkdir -p .tmp
@node ./tools/coverage-report.ts -i coverage.txt -o .tmp/coverage.md
@echo "Wrote .tmp/coverage.md"
.PHONY: test-dind
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
@./scripts/test-dind.sh $(TARGET)
@@ -218,7 +224,7 @@ docker: ## build the docker image
.PHONY: clean
clean: ## delete binary and coverage files
$(GO) clean -x -i ./...
rm -rf coverage.txt $(EXECUTABLE) $(DIST)
rm -rf coverage.txt .tmp $(EXECUTABLE) $(DIST)
.PHONY: version
version: ## print the version

View File

@@ -85,6 +85,8 @@ docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRA
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well — otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist.
### Image flavours
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
@@ -121,6 +123,8 @@ Two processes have to run side by side here (the Docker daemon and the runner),
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
> **The UID is fixed at 1000.** It comes from the `rootless` user baked into the upstream `docker:dind-rootless` base image, and the bundled daemon always listens on `/run/user/1000/docker.sock` inside the container, so running this flavour as a different user (`--user 1001`) does not work. If you need the runner to talk to a *host* rootless daemon that runs under some other UID, use the `basic` flavour instead and bind-mount that daemon's socket (see [examples/vm/rootless-docker.md](examples/vm/rootless-docker.md)); pointing `DOCKER_HOST` at a host socket from inside `dind-rootless` will not work. Changing the UID otherwise means rebuilding the image from a base with a different `rootless` user.
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
### Configuration
@@ -143,23 +147,68 @@ Every option is described in [config.example.yaml](internal/pkg/config/config.ex
#### Without a config file
If you omit `-c`, built-in defaults apply (same as an empty YAML document). A small set of **deprecated** environment variables can still override parts of that default config, but **only when no `-c` path was given**; they are ignored if you use a config file:
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
| Variable | Effect |
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
### Labels
Labels decide **which jobs a runner accepts** and **how it runs them**. A job's `runs-on` is matched against the runner's label names; the first match wins and selects the execution environment for that job.
A label is written as:
```text
<name>[:<schema>[:<args>]]
```
| Part | Meaning |
| --- | --- |
| `GITEA_DEBUG` | If true, sets log level to `debug` |
| `GITEA_TRACE` | If true, sets log level to `trace` |
| `GITEA_RUNNER_CAPACITY` | Concurrent jobs (integer) |
| `GITEA_RUNNER_FILE` | Registration state file path (default `.runner`) |
| `GITEA_RUNNER_ENVIRON` | Extra job env vars as comma-separated `KEY:VALUE` pairs |
| `GITEA_RUNNER_ENV_FILE` | Path to an env file merged into job env (same idea as `runner.env_file` in YAML) |
| `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. |
Prefer a YAML file for all settings.
Two schemas are supported:
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
```text
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
```
- **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there:
```text
macos:host
```
So with the labels
```text
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
```
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
If a job's `runs-on` matches none of the runner's labels, the job still runs, in the default `docker.gitea.com/runner-images:ubuntu-latest` image. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
#### Registration vs config labels
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
The `daemon` command also accepts `--labels` (which defaults to the `GITEA_RUNNER_LABELS` environment variable), so the labels of an already registered runner can be changed without deleting its registration file. The most explicit source wins:
```
--labels / GITEA_RUNNER_LABELS > runner.labels in the config file > labels in the .runner file
```
Whenever the resulting labels differ from the ones in the registration file, they are written back to it and re-declared to the Gitea instance on startup.
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
#### Caching (`actions/cache`)
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
@@ -175,6 +224,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
dir: /data/actcache
port: 8088
external_secret: "replace-with-a-strong-random-secret"
# external_secret_file: /path/to/secret # secret can also be passed via a file
```
2. Start the server:
@@ -189,6 +239,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
cache:
external_server: "http://<cache-server-host>:8088/"
external_secret: "replace-with-a-strong-random-secret" # must match the server
# external_secret_file: /path/to/secret # secret can also be passed via a file
```
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.

View File

@@ -390,6 +390,43 @@ func TestMkdirFsImplSafeResolve(t *testing.T) {
}
}
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
fsys := readWriteFSImpl{}
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
w, err := fsys.OpenWritable(name)
require.NoError(t, err)
_, err = w.Write([]byte("first"))
require.NoError(t, err)
require.NoError(t, w.Close())
w, err = fsys.OpenAppendable(name)
require.NoError(t, err)
_, err = w.Write([]byte("-second"))
require.NoError(t, err)
require.NoError(t, w.Close())
got, err := os.ReadFile(name)
require.NoError(t, err)
require.Equal(t, "first-second", string(got))
w, err = fsys.OpenWritable(name)
require.NoError(t, err)
_, err = w.Write([]byte("replaced"))
require.NoError(t, err)
require.NoError(t, w.Close())
got, err = os.ReadFile(name)
require.NoError(t, err)
require.Equal(t, "replaced", string(got))
}
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
require.NotNil(t, cancel)
cancel()
}
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
assert := assert.New(t)

View File

@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"testing"
"github.com/sirupsen/logrus"
)
func TestDryrunContext(t *testing.T) {
ctx := context.Background()
if Dryrun(ctx) {
t.Fatal("plain context should not be dryrun")
}
if !Dryrun(WithDryrun(ctx, true)) {
t.Fatal("WithDryrun(true) should set dryrun")
}
if Dryrun(WithDryrun(ctx, false)) {
t.Fatal("WithDryrun(false) should clear dryrun")
}
}
func TestJobErrorContainer(t *testing.T) {
ctx := context.Background()
err := errors.New("job failed")
SetJobError(ctx, err)
if got := JobError(ctx); got != nil {
t.Fatalf("JobError without container = %v, want nil", got)
}
ctx = WithJobErrorContainer(ctx)
SetJobError(ctx, err)
if got := JobError(ctx); !errors.Is(got, err) {
t.Fatalf("JobError = %v, want %v", got, err)
}
}
func TestLoggerAndHookContext(t *testing.T) {
ctx := context.Background()
if Logger(ctx) != logrus.StandardLogger() {
t.Fatal("plain context should use standard logger")
}
if LoggerHook(ctx) != nil {
t.Fatal("plain context should not have a logger hook")
}
logger := logrus.New()
ctx = WithLogger(ctx, logger)
if Logger(ctx) != logger {
t.Fatal("WithLogger should set logger")
}
hook := testHook{}
ctx = WithLoggerHook(ctx, hook)
if LoggerHook(ctx) != hook {
t.Fatal("WithLoggerHook should set hook")
}
}
type testHook struct{}
func (testHook) Levels() []logrus.Level {
return logrus.AllLevels
}
func (testHook) Fire(*logrus.Entry) error {
return nil
}

View File

@@ -7,6 +7,8 @@ package common
import (
"context"
"errors"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
@@ -170,3 +172,43 @@ func TestNewParallelExecutorCanceled(t *testing.T) {
assert.Equal(int32(3), count.Load())
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
}
func TestExecutorConditionalsAndFinally(t *testing.T) {
ctx := context.Background()
var calls []string
record := func(name string) Executor {
return func(ctx context.Context) error {
calls = append(calls, name)
return nil
}
}
require.NoError(t, record("if-true").If(func(context.Context) bool { return true })(ctx))
require.NoError(t, record("if-false").If(func(context.Context) bool { return false })(ctx))
require.NoError(t, record("if-not").IfNot(func(context.Context) bool { return false })(ctx))
require.NoError(t, record("if-bool").IfBool(true)(ctx))
require.NoError(t, record("main").Finally(record("finally"))(ctx))
want := []string{"if-true", "if-not", "if-bool", "main", "finally"}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("calls = %v, want %v", calls, want)
}
}
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
mainErr := errors.New("main failed")
finalErr := errors.New("cleanup failed")
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(context.Background())
require.Error(t, err)
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
t.Fatalf("finally error = %q, want both cleanup and original error", err)
}
}
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")
}
}

View File

@@ -261,6 +261,10 @@ type NewGitCloneExecutorInput struct {
// 0 for full clone.
Depth int
// Quiet drops the informational clone line to debug level, for callers that log their own
// download summary (the setup section's action report).
Quiet bool
// For Gitea
InsecureSkipTLS bool
}
@@ -347,7 +351,11 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
if input.Quiet {
logger.Debugf("git clone '%s' # ref=%s", input.URL, input.Ref)
} else {
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
}
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
defer AcquireCloneLock(input.Dir)()

View File

@@ -17,6 +17,10 @@ import (
"testing"
"time"
"gitea.com/gitea/runner/act/common"
log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -50,6 +54,13 @@ func TestFindGitSlug(t *testing.T) {
}
}
func TestErrorWrapsCommitAndCause(t *testing.T) {
err := &Error{err: ErrShortRef, commit: "abc123"}
require.Equal(t, ErrShortRef.Error(), err.Error())
require.ErrorIs(t, err, ErrShortRef)
require.Equal(t, "abc123", err.Commit())
}
func cleanGitHooks(dir string) error {
hooksDir := filepath.Join(dir, ".git", "hooks")
files, err := os.ReadDir(hooksDir)
@@ -96,6 +107,22 @@ func TestFindGitRemoteURL(t *testing.T) {
assert.Equal(remoteURL, u)
}
func TestFindGithubRepoUsesOriginAndCustomRemote(t *testing.T) {
basedir := t.TempDir()
require.NoError(t, gitCmd("init", basedir))
require.NoError(t, cleanGitHooks(basedir))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", "https://github.com/owner/repo.git"))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "ghe", "git@git.example.com:team/project.git"))
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
require.NoError(t, err)
require.Equal(t, "owner/repo", slug)
slug, err = FindGithubRepo(context.Background(), basedir, "git.example.com", "ghe")
require.NoError(t, err)
require.Equal(t, "team/project", slug)
}
func TestGitFindRef(t *testing.T) {
basedir := t.TempDir()
@@ -381,6 +408,44 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
})
}
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
// Quiet callers report the download themselves, so the clone line must not reach the job log.
for name, quiet := range map[string]bool{"quiet": true, "not quiet": false} {
t.Run(name, func(t *testing.T) {
logger, hook := logrustest.NewNullLogger()
logger.SetLevel(log.InfoLevel)
ctx := common.WithLogger(context.Background(), logger.WithField("job", "j1"))
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir,
Ref: "main",
Dir: t.TempDir(),
Quiet: quiet,
})(ctx))
var cloneLines int
for _, entry := range hook.AllEntries() {
if strings.HasPrefix(entry.Message, "git clone ") {
cloneLines++
}
}
if quiet {
assert.Zero(t, cloneLines)
} else {
assert.Equal(t, 1, cloneLines)
}
})
}
}
func TestGitCloneExecutorShallow(t *testing.T) {
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
remoteDir := t.TempDir()

View File

@@ -498,6 +498,79 @@ func TestParseDevice(t *testing.T) {
}
}
func TestParseDeviceByServerOS(t *testing.T) {
tests := []struct {
name string
device string
serverOS string
want container.DeviceMapping
wantErr string
}{
{
name: "linux source only",
device: "/dev/snd",
serverOS: "linux",
want: container.DeviceMapping{
PathOnHost: "/dev/snd",
PathInContainer: "/dev/snd",
CgroupPermissions: "rwm",
},
},
{
name: "linux source and mode",
device: "/dev/snd:rw",
serverOS: "linux",
want: container.DeviceMapping{
PathOnHost: "/dev/snd",
PathInContainer: "/dev/snd",
CgroupPermissions: "rw",
},
},
{
name: "linux source target and mode",
device: "/dev/snd:/container/snd:m",
serverOS: "linux",
want: container.DeviceMapping{
PathOnHost: "/dev/snd",
PathInContainer: "/container/snd",
CgroupPermissions: "m",
},
},
{
name: "windows passes value through",
device: `class/GUID`,
serverOS: "windows",
want: container.DeviceMapping{
PathOnHost: `class/GUID`,
},
},
{
name: "invalid server OS",
device: "/dev/snd",
serverOS: "plan9",
wantErr: "unknown server OS: plan9",
},
{
name: "too many linux fields",
device: "/dev/snd:/container/snd:rw:extra",
serverOS: "linux",
wantErr: "invalid device specification: /dev/snd:/container/snd:rw:extra",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseDevice(tc.device, tc.serverOS)
if tc.wantErr != "" {
assert.Error(t, err, tc.wantErr)
return
}
assert.NilError(t, err)
assert.Equal(t, got, tc.want)
})
}
}
func TestParseNetworkConfig(t *testing.T) {
tests := []struct {
name string
@@ -930,6 +1003,82 @@ func TestValidateDevice(t *testing.T) {
}
}
func TestValidateDeviceByServerOS(t *testing.T) {
tests := []struct {
name string
value string
serverOS string
want string
wantError string
}{
{
name: "linux preserves three-field container path",
value: "/host:/container/../device:rw",
serverOS: "linux",
want: "/host:/container/../device:rw",
},
{
name: "linux source path can be relative when target is absolute",
value: "relative-host:/container/device",
serverOS: "linux",
want: "relative-host:/container/device",
},
{
name: "windows defers validation",
value: `class/GUID`,
serverOS: "windows",
want: `class/GUID`,
},
{
name: "linux rejects bad mode",
value: "/host:/container:ro",
serverOS: "linux",
wantError: "bad mode specified: ro",
},
{
name: "linux target must be absolute",
value: "/host:relative",
serverOS: "linux",
wantError: "relative is not an absolute path",
},
{
name: "unknown server OS",
value: "/dev/snd",
serverOS: "plan9",
wantError: "unknown server OS: plan9",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := validateDevice(tc.value, tc.serverOS)
if tc.wantError != "" {
assert.Error(t, err, tc.wantError)
return
}
assert.NilError(t, err)
assert.Equal(t, got, tc.want)
})
}
}
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
got, err := validateDeviceCgroupRule("c 1:3 rwm")
assert.NilError(t, err)
assert.Equal(t, got, "c 1:3 rwm")
_, err = validateDeviceCgroupRule("invalid")
assert.Error(t, err, "invalid device cgroup format 'invalid'")
if invalidParameter(nil) != nil {
t.Fatal("invalidParameter(nil) should be nil")
}
err = invalidParameter(errors.New("bad input"))
assert.Assert(t, err != nil)
var invalid interface{ InvalidParameter() }
assert.Assert(t, errors.As(err, &invalid))
}
func TestParseSystemPaths(t *testing.T) {
tests := []struct {
doc string

View File

@@ -0,0 +1,86 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"errors"
"fmt"
"io"
"slices"
"github.com/kballard/go-shellquote"
"github.com/spf13/pflag"
)
const (
pullPolicyAlways = "always"
pullPolicyMissing = "missing"
pullPolicyNever = "never"
)
var pullPolicies = []string{pullPolicyAlways, pullPolicyMissing, pullPolicyNever}
// createFlags are the flags docker/cli registers on the `create` and `run` commands
// instead of in addFlags, so they are not part of containerOptions.
type createFlags struct {
platform string
pull string
name string
useAPISocket bool
}
func registerCreateFlags(flags *pflag.FlagSet) *createFlags {
cf := new(createFlags)
flags.StringVar(&cf.platform, "platform", "", "Set platform if server is multi-platform capable")
flags.StringVar(&cf.pull, "pull", pullPolicyMissing, `Pull image before creating ("always", "missing", "never")`)
flags.StringVar(&cf.name, "name", "", "Assign a name to the container")
flags.BoolVar(&cf.useAPISocket, "use-api-socket", false, "Bind mount Docker API socket and required auth")
// Accepted without effect: pull progress is only logged at debug level, and docker
// no longer implements content trust.
flags.BoolP("quiet", "q", false, "Suppress the pull output")
flags.Bool("disable-content-trust", true, "Skip image verification (deprecated)")
return cf
}
// parseContainerOptions parses a container options string. The flags are returned even
// on error, holding whatever was read before the failure.
func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *createFlags, error) {
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
flags.SetOutput(io.Discard)
copts := addFlags(flags)
cf := registerCreateFlags(flags)
args, err := shellquote.Split(options)
if err != nil {
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
}
if err := flags.Parse(args); err != nil {
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
}
return flags, copts, cf, nil
}
// createFlagsFromOptions reads the create-level flags that have to be known before the
// container is created. Malformed options keep the defaults here and are reported by
// mergeContainerConfigs at create time.
func createFlagsFromOptions(options string) *createFlags {
_, _, cf, _ := parseContainerOptions(options)
return cf
}
func (cf *createFlags) validate() error {
if !slices.Contains(pullPolicies, cf.pull) {
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
}
if cf.useAPISocket {
return errors.New("--use-api-socket is not supported, use the runner's container.docker_host setting to expose a docker socket")
}
return nil
}

View File

@@ -0,0 +1,61 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateFlagsFromOptions(t *testing.T) {
for _, tc := range []struct {
options string
platform string
pull string
}{
{"", "", pullPolicyMissing},
{"-v /a:/b --platform=linux/arm64 --pull always", "linux/arm64", pullPolicyAlways},
{"--platform linux/arm/v7 --pull never", "linux/arm/v7", pullPolicyNever},
{`--platform "linux/amd64`, "", pullPolicyMissing}, // malformed, defaults kept
} {
t.Run(tc.options, func(t *testing.T) {
cf := createFlagsFromOptions(tc.options)
assert.Equal(t, tc.platform, cf.platform)
assert.Equal(t, tc.pull, cf.pull)
})
}
}
func TestCreateFlagsValidate(t *testing.T) {
for _, tc := range []struct {
options string
wantErr string
}{
{"--quiet --disable-content-trust --name mine", ""},
{"--pull sometimes", `invalid --pull option "sometimes"`},
{"--use-api-socket", "--use-api-socket is not supported"},
} {
t.Run(tc.options, func(t *testing.T) {
err := createFlagsFromOptions(tc.options).validate()
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
}
func TestNewContainerAppliesCreateFlags(t *testing.T) {
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
cr := NewContainer(input).(*containerReference)
assert.Equal(t, "linux/arm64", input.Platform)
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
NewContainer(kept)
assert.Equal(t, "linux/amd64", kept.Platform)
}

View File

@@ -72,7 +72,9 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
_ = logDockerResponse(logger, reader, err != nil)
}
return err
if err != nil {
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
}
}
return nil
}

View File

@@ -35,7 +35,6 @@ import (
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/gobwas/glob"
"github.com/joho/godotenv"
"github.com/kballard/go-shellquote"
"github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
@@ -43,7 +42,6 @@ import (
"github.com/moby/moby/api/types/system"
"github.com/moby/moby/client"
specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spf13/pflag"
)
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
@@ -57,6 +55,12 @@ const drainGracePeriod = 2 * time.Second
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
cr := new(containerReference)
cr.input = input
// Resolved up front because the image pull runs before the container is created.
cf := createFlagsFromOptions(input.Options)
if cf.platform != "" {
cr.input.Platform = cf.platform
}
cr.pullPolicy = cf.pull
return cr
}
@@ -137,6 +141,11 @@ func (cr *containerReference) Start(attach bool) common.Executor {
}
func (cr *containerReference) Pull(forcePull bool) common.Executor {
if cr.pullPolicy == pullPolicyNever {
return common.NewInfoExecutor("docker pull skipped image=%s, --pull=never in the options", cr.input.Image)
}
forcePull = forcePull || cr.pullPolicy == pullPolicyAlways
return common.
NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
Then(
@@ -232,11 +241,12 @@ func (cr *containerReference) ReplaceLogWriter(stdout, stderr io.Writer) (io.Wri
}
type containerReference struct {
cli client.APIClient
id string
input *NewContainerInput
UID int
GID int
cli client.APIClient
id string
input *NewContainerInput
pullPolicy string
UID int
GID int
// attachDone is closed by the attach() streaming goroutine once it has
// drained and flushed the container's output. wait() blocks on it so the
// tail of the log lands before the step proceeds.
@@ -376,6 +386,11 @@ func (cr *containerReference) find() common.Executor {
}
}
// isContainerGone reports whether a failed remove still left the container gone (NotFound or Conflict).
func isContainerGone(err error) bool {
return cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err)
}
func (cr *containerReference) remove() common.Executor {
return func(ctx context.Context) error {
if cr.id == "" {
@@ -387,7 +402,7 @@ func (cr *containerReference) remove() common.Executor {
RemoveVolumes: true,
Force: true,
})
if err != nil {
if err != nil && !isContainerGone(err) {
logger.Error(fmt.Errorf("failed to remove container: %w", err))
}
@@ -406,17 +421,13 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
}
// parse configuration from CLI container.options
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
copts := addFlags(flags)
optionsArgs, err := shellquote.Split(input.Options)
flags, copts, cf, err := parseContainerOptions(input.Options)
if err != nil {
return nil, nil, fmt.Errorf("Cannot split container options: '%s': '%w'", input.Options, err)
return nil, nil, err
}
err = flags.Parse(optionsArgs)
if err != nil {
return nil, nil, fmt.Errorf("Cannot parse container options: '%s': '%w'", input.Options, err)
if err := cf.validate(); err != nil {
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
}
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
@@ -471,6 +482,9 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
}
hostConfig.Binds = binds
hostConfig.Mounts = mounts
if cf.name != "" {
logger.Warn("--name in the options will be ignored.")
}
if len(copts.netMode.Value()) > 0 {
logger.Warn("--network and --net in the options will be ignored.")
}

View File

@@ -116,6 +116,11 @@ func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.Co
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
}
func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
}
type endlessReader struct {
io.Reader
}
@@ -381,6 +386,40 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
client.AssertExpectations(t)
}
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
// be logged as one.
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
for _, tc := range []struct {
name string
err error
wantLogs bool
}{
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress")},
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
{name: "removed cleanly", err: nil},
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantLogs: true},
} {
t.Run(tc.name, func(t *testing.T) {
logger, hook := test.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
client := &mockDockerClient{}
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
cr := &containerReference{id: "abc", cli: client}
require.NoError(t, cr.remove()(ctx))
assert.Empty(t, cr.id)
if tc.wantLogs {
assert.Len(t, hook.AllEntries(), 1)
} else {
assert.Empty(t, hook.AllEntries())
}
client.AssertExpectations(t)
})
}
}
// find() must drop a stale cached id so later Copy/Exec don't hit the
// daemon with a torn-down container.
func TestFindRevalidatesStaleID(t *testing.T) {

View File

@@ -17,6 +17,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
@@ -43,6 +44,25 @@ type HostEnvironment struct {
CleanUp func()
StdOut io.Writer
AllocatePTY bool // allocate a pseudo-TTY for each step's process
// procGroup owns every process the job's steps start. Atomic: Remove may read
// it while a step is still starting.
procGroupOnce sync.Once
procGroup atomic.Pointer[process.Group]
}
// processGroup returns the job-scoped process group, creating it on first use.
// Returns nil if the job object could not be created; Group is nil-safe.
func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group {
e.procGroupOnce.Do(func() {
group, err := process.NewGroup()
if err != nil {
common.Logger(ctx).Warnf("could not create the job's process group; processes a step leaves behind can only be reclaimed by the workspace scan: %v", err)
return
}
e.procGroup.Store(group)
})
return e.procGroup.Load()
}
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
@@ -324,11 +344,8 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
cmd.Dir = wd
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
// Kill the step's whole process tree on cancellation (a step often launches a
// shell that spawns further background or GUI children) and bound the post-exit
// I/O wait, so an orphan inheriting cmd's stdout/stderr pipe can never hang
// cmd.Wait() and the runner. See process.TreeKill. The PTY path below may
// override SysProcAttr, but never touches Cancel/WaitDelay.
// Kills the step's whole tree on cancellation and bounds the post-exit I/O
// wait, so an orphan holding cmd's stdout pipe cannot hang cmd.Wait().
treeKill := process.NewTreeKill(cmd)
var ppty *os.File
@@ -360,6 +377,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
if err := cmd.Start(); err != nil {
return err
}
// Assign before the step's Killer so the step's job nests inside the group's;
// cancellation still scopes to this step's tree.
if err := e.processGroup(ctx).Assign(cmd.Process); err != nil {
common.Logger(ctx).Warnf("could not assign the step's process to the job's process group; a process it leaves behind may outlive the job: %v", err)
}
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
} else {
@@ -407,13 +429,12 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
return parseEnvFile(e, srcPath, env)
}
// removeAll is the filesystem delete used by removeAllWithContext. A package
// var so tests can substitute a blocking stub without patching os.RemoveAll.
// removeAll is a var so tests can substitute a blocking stub.
var removeAll = os.RemoveAll
// removeAllWithContext runs removeAll in a goroutine and returns once it
// finishes or ctx is cancelled. On cancellation the goroutine is left running —
// a delete blocked inside a syscall cannot be interrupted (see runWithTimeout).
// removeAllWithContext returns once the delete finishes or ctx is cancelled. On
// cancellation the goroutine leaks: a delete inside a syscall cannot be
// interrupted (see runWithTimeout).
func removeAllWithContext(ctx context.Context, path string) error {
done := make(chan error, 1)
go func() { done <- removeAll(path) }()
@@ -455,17 +476,12 @@ func removePathWithRetry(ctx context.Context, path string) error {
return lastErr
}
// buildWindowsWorkspaceKillScript builds a PowerShell command that `taskkill
// /T /F`s every process tree whose ExecutablePath or CommandLine references one
// of the given absolute workspace dirs, releasing file handles for cleanup.
//
// Win32_Process is used because it exposes both ExecutablePath and CommandLine
// (Get-Process doesn't, wmic is deprecated). Both match the dir+separator
// prefix, so a sibling dir sharing a name prefix (job1 vs job10) is spared.
// Ordinal String methods, not -like, so path metacharacters ([ ] ? *) stay
// literal.
//
// Pure function so the quote-escaping can be unit-tested without PowerShell.
// buildWindowsWorkspaceKillScript builds a PowerShell command that taskkills
// every process tree whose ExecutablePath or CommandLine references one of the
// given workspace dirs, releasing file handles for cleanup. Win32_Process
// exposes both fields (Get-Process doesn't, wmic is deprecated); matching is on
// the dir+separator prefix via ordinal String methods, so a name-prefix sibling
// (job1 vs job10) is spared and path metacharacters stay literal.
func buildWindowsWorkspaceKillScript(dirs []string) string {
quoted := make([]string, len(dirs))
for i, d := range dirs {
@@ -501,9 +517,8 @@ func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
logger := common.Logger(ctx)
// Workspace dirs we own. Any process running from or referencing one is a
// leftover job process. ToolCache is shared across jobs; Workdir only when
// we own it (else it's a caller-provided checkout, e.g. act local mode).
// Dirs we own; a process referencing one is a leftover. ToolCache is shared
// across jobs, and Workdir may be a caller-owned checkout.
owned := []string{e.Path, e.TmpDir}
if e.CleanWorkdir {
owned = append(owned, e.Workdir)
@@ -530,21 +545,24 @@ func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
if err != nil {
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
}
// Win32_Process exposes no working directory, so the scan above misses a
// process that merely runs in a workspace dir while pinning a handle on it.
if killed, err := process.KillProcessesWithCWDUnder(killCtx, dirs); err != nil {
logger.Debugf("workspace process kill by working directory reported errors: %v", err)
} else if killed > 0 {
logger.Debugf("terminated %d leftover process(es) by workspace working directory", killed)
}
}
// hostCleanupTimeout bounds each filesystem-teardown phase of the host
// environment so a single stalled delete cannot wedge the runner slot forever.
// A var (not const) so tests can shrink it.
// hostCleanupTimeout bounds each teardown phase so one stalled delete cannot
// wedge the runner slot. A var so tests can shrink it.
var hostCleanupTimeout = 30 * time.Second
// runWithTimeout runs fn in a goroutine and returns once it finishes or timeout
// elapses, whichever comes first. On timeout the goroutine is left running — an
// os.RemoveAll blocked inside a delete syscall (AV/EDR filter drivers, an
// unresponsive network mount, a dying disk) cannot be interrupted — and
// context.DeadlineExceeded is returned. Leaking the goroutine and the scratch
// state it was deleting is strictly better than blocking the caller forever and
// permanently losing the runner's capacity slot; the leaked scratch dir is
// reclaimed later by the runner's idle stale-dir sweep.
// runWithTimeout returns context.DeadlineExceeded once timeout elapses, leaking
// the goroutine: a delete blocked in a syscall (AV filter driver, dead network
// mount) cannot be interrupted, and leaking scratch state beats losing the
// runner's capacity slot forever. The idle stale-dir sweep reclaims it later.
func runWithTimeout(fn func(), timeout time.Duration) error {
done := make(chan struct{})
go func() {
@@ -565,14 +583,15 @@ func (e *HostEnvironment) Remove() common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
// Ensure any lingering child processes are ended before attempting
// to remove the workspace (Windows file locks otherwise prevent cleanup).
// End lingering processes before removing the workspace; on Windows their
// file locks block cleanup. Closing the group is deterministic, the scan a net.
if err := e.procGroup.Load().Close(); err != nil {
logger.Debugf("closing the job's process group failed: %v", err)
}
e.terminateRunningProcesses(ctx)
// Only removes per-job misc state. Must not remove the cache/toolcache root.
// Bound it: CleanUp is a caller-supplied, typically unbounded os.RemoveAll,
// and a delete stalled by a filesystem filter driver would otherwise hang
// the job forever at "Cleaning up container" and hold the capacity slot.
// Removes per-job misc state only, never the toolcache root. Bounded because
// CleanUp is a caller-supplied, typically unbounded os.RemoveAll.
if e.CleanUp != nil {
logger.Debugf("running host environment cleanup callback")
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
@@ -603,8 +622,7 @@ func (e *HostEnvironment) Remove() common.Executor {
return errors.Join(errs...)
}
}
// Bounded teardown timed out; warnings already logged above. Do not
// fail job completion — leaked scratch is reclaimed by the idle sweep.
// Teardown timed out; warned above. Do not fail job completion over it.
return nil
}
}

View File

@@ -13,6 +13,9 @@ import (
"strings"
"gitea.com/gitea/runner/act/common"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
@@ -28,11 +31,19 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
if err != nil && err != io.EOF {
return err
}
s := bufio.NewScanner(reader)
// Decode by BOM: Windows PowerShell 5.1 redirection writes UTF-16, and some
// tools emit a UTF-8 BOM. Without a BOM the file is read as UTF-8, as before.
decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
s := bufio.NewScanner(decoded)
// Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB.
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for s.Scan() {
line := s.Text()
// GitHub's runner ignores blank lines
if strings.TrimSpace(line) == "" {
continue
}
singleLineEnv := strings.Index(line, "=")
multiLineEnv := strings.Index(line, "<<")
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {

View File

@@ -13,6 +13,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/unicode"
)
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
@@ -64,6 +66,63 @@ func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
assert.Contains(t, err.Error(), "reading env file")
}
// Regression test: a blank line used to fail the job at "Complete Job", after
// every step had already been recorded as successful.
func TestParseEnvFileBlankLines(t *testing.T) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "bar", env["FOO"])
assert.Equal(t, "qux", env["BAZ"])
}
// blank lines inside a heredoc value are content, not separators
func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\n\nline2\nEOF\n"), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "line1\n\nline2", env["FOO"])
}
func TestParseEnvFileUTF8BOM(t *testing.T) {
e, envPath := newTestHostEnv(t)
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("FOO=bar\n")...)
require.NoError(t, os.WriteFile(envPath, content, 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "bar", env["FOO"])
}
// Windows host mode: PowerShell 5.1 redirection writes UTF-16, which used to be
// unrecognisable as KEY=VALUE, so the writes were silently ignored.
func TestParseEnvFileUTF16(t *testing.T) {
tests := []struct {
name string
encoder *encoding.Encoder
}{
{"little endian", unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewEncoder()},
{"big endian", unicode.UTF16(unicode.BigEndian, unicode.UseBOM).NewEncoder()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e, envPath := newTestHostEnv(t)
content, err := tt.encoder.Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
require.NoError(t, err)
require.NoError(t, os.WriteFile(envPath, content, 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "bar", env["FOO"])
assert.Equal(t, "line1", env["MULTI"])
})
}
}
func TestParseEnvFileMissingDelimiter(t *testing.T) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))

View File

@@ -274,8 +274,17 @@ func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pr
return true, nil
}
// jobStatus returns the current job status, treating a nil Job context as an
// empty status so status-check functions never panic on a nil dereference.
func (impl *interperterImpl) jobStatus() string {
if impl.env.Job == nil {
return ""
}
return impl.env.Job.Status
}
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
return impl.env.Job.Status == "success", nil
return impl.jobStatus() == "success", nil
}
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
@@ -292,9 +301,9 @@ func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pr
}
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
return impl.env.Job.Status == "failure", nil
return impl.jobStatus() == "failure", nil
}
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
return impl.env.Job.Status == "cancelled", nil
return impl.jobStatus() == "cancelled", nil
}

View File

@@ -254,3 +254,27 @@ func TestFunctionFormat(t *testing.T) {
})
}
}
func TestStatusFunctionsNilJob(t *testing.T) {
// A nil Job context must not panic: the status-check functions should treat
// it as an empty status and return false rather than dereferencing nil.
env := &EvaluationEnvironment{}
table := []struct {
input string
context string
name string
}{
{"cancelled()", "job", "cancelled-nil-job"},
{"success()", "step", "step-success-nil-job"},
{"failure()", "step", "step-failure-nil-job"},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, false, output)
})
}
}

View File

@@ -13,6 +13,7 @@ import (
"runtime"
"strings"
"testing"
"time"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
@@ -221,3 +222,63 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "target", resolved)
}
func TestDefaultFsOpenReadlinkAndWalk(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating symlinks requires elevated privileges on Windows")
}
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644))
require.NoError(t, os.Symlink("file.txt", filepath.Join(root, "link.txt")))
fsys := &DefaultFs{}
var walked []string
require.NoError(t, fsys.Walk(root, func(path string, info os.FileInfo, err error) error {
require.NoError(t, err)
walked = append(walked, info.Name())
return nil
}))
require.Contains(t, walked, "file.txt")
require.Contains(t, walked, "link.txt")
file, err := fsys.Open(filepath.Join(root, "file.txt"))
require.NoError(t, err)
data, err := io.ReadAll(file)
require.NoError(t, err)
require.NoError(t, file.Close())
require.Equal(t, "content", string(data))
link, err := fsys.Readlink(filepath.Join(root, "link.txt"))
require.NoError(t, err)
require.Equal(t, "file.txt", link)
}
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
fc := &FileCollector{Fs: &memoryFs{Filesystem: memfs.New()}}
walk := fc.CollectFiles(cancelledContext(t), nil)
err := walk("file", fakeFileInfo{name: "file"}, nil)
require.EqualError(t, err, "copy cancelled")
err = walk("file", fakeFileInfo{name: "file"}, os.ErrPermission)
require.ErrorIs(t, err, os.ErrPermission)
}
func cancelledContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}
type fakeFileInfo struct {
name string
}
func (f fakeFileInfo) Name() string { return f.name }
func (f fakeFileInfo) Size() int64 { return 0 }
func (f fakeFileInfo) Mode() os.FileMode { return 0o644 }
func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
func (f fakeFileInfo) IsDir() bool { return false }
func (f fakeFileInfo) Sys() any { return nil }

View File

@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package lookpath
import (
"errors"
"io/fs"
"os"
"path/filepath"
"testing"
)
type testEnv map[string]string
func (e testEnv) Getenv(name string) string {
return e[name]
}
func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "tool")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
got, err := LookPath2("tool", testEnv{"PATH": string(filepath.ListSeparator) + dir})
if err != nil {
t.Fatal(err)
}
if got != exe {
t.Fatalf("LookPath2() = %q, want %q", got, exe)
}
}
func TestLookPath2DirectPathDoesNotSearchPath(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "tool")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
got, err := LookPath2(exe, testEnv{"PATH": ""})
if err != nil {
t.Fatal(err)
}
if got != exe {
t.Fatalf("LookPath2() = %q, want %q", got, exe)
}
}
func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "not-executable")
if err := os.WriteFile(file, []byte("plain text"), 0o644); err != nil {
t.Fatal(err)
}
_, err := LookPath2(file, testEnv{"PATH": dir})
var pathErr *Error
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, fs.ErrPermission) {
t.Fatalf("LookPath2(non-executable) error = %v, want fs.ErrPermission wrapped in *Error", err)
}
if pathErr.Error() != fs.ErrPermission.Error() {
t.Fatalf("Error() = %q, want %q", pathErr.Error(), fs.ErrPermission.Error())
}
_, err = LookPath2("missing", testEnv{"PATH": dir})
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, ErrNotFound) {
t.Fatalf("LookPath2(missing) error = %v, want ErrNotFound wrapped in *Error", err)
}
}

63
act/model/action_test.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
import (
"strings"
"testing"
)
func TestReadActionDefaultsAndCaseInsensitiveUsing(t *testing.T) {
action, err := ReadAction(strings.NewReader(`
name: example
runs:
using: NoDe24
main: dist/index.js
`))
if err != nil {
t.Fatal(err)
}
if action.Runs.Using != ActionRunsUsingNode24 {
t.Fatalf("using = %q, want %q", action.Runs.Using, ActionRunsUsingNode24)
}
if action.Runs.PreIf != "always()" {
t.Fatalf("pre-if = %q, want always()", action.Runs.PreIf)
}
if action.Runs.PostIf != "always()" {
t.Fatalf("post-if = %q, want always()", action.Runs.PostIf)
}
}
func TestReadActionPreservesExplicitConditions(t *testing.T) {
action, err := ReadAction(strings.NewReader(`
runs:
using: composite
pre-if: success()
post-if: failure()
steps:
- run: echo hello
`))
if err != nil {
t.Fatal(err)
}
if action.Runs.PreIf != "success()" || action.Runs.PostIf != "failure()" {
t.Fatalf("conditions = %q/%q, want explicit values", action.Runs.PreIf, action.Runs.PostIf)
}
if !action.Runs.Using.IsComposite() || action.Runs.Using.IsDocker() || action.Runs.Using.IsNode() {
t.Fatalf("unexpected using predicates for %q", action.Runs.Using)
}
}
func TestReadActionRejectsUnknownUsing(t *testing.T) {
_, err := ReadAction(strings.NewReader(`
runs:
using: node99
`))
if err == nil {
t.Fatal("expected unknown runs.using to fail")
}
if !strings.Contains(err.Error(), "node99") {
t.Fatalf("error = %q, want invalid value", err)
}
}

View File

@@ -6,10 +6,12 @@ package model
import (
"path/filepath"
"strings"
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type WorkflowPlanTest struct {
@@ -65,3 +67,133 @@ func TestWorkflow(t *testing.T) {
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, result)
}
func TestNewSingleWorkflowPlannerAndPlanMethods(t *testing.T) {
planner, err := NewSingleWorkflowPlanner("ci.yml", strings.NewReader(`
name: CI
on: [push, pull_request]
jobs:
build:
name: Build project
runs-on: ubuntu-latest
steps:
- run: make build
test:
needs: build
runs-on: ubuntu-latest
steps:
- run: make test
`))
require.NoError(t, err)
assert.Equal(t, []string{"pull_request", "push"}, planner.GetEvents())
eventPlan, err := planner.PlanEvent("push")
require.NoError(t, err)
require.Len(t, eventPlan.Stages, 2)
assert.Equal(t, []string{"build"}, eventPlan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, eventPlan.Stages[1].GetJobIDs())
assert.Equal(t, len("Build project"), eventPlan.MaxRunNameLen())
assert.Equal(t, "Build project", eventPlan.Stages[0].Runs[0].String())
assert.Equal(t, "build", eventPlan.Stages[0].Runs[0].JobID)
assert.NotNil(t, eventPlan.Stages[0].Runs[0].Job())
jobPlan, err := planner.PlanJob("test")
require.NoError(t, err)
require.Len(t, jobPlan.Stages, 2)
assert.Equal(t, []string{"build"}, jobPlan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, jobPlan.Stages[1].GetJobIDs())
allPlan, err := planner.PlanAll()
require.NoError(t, err)
require.Len(t, allPlan.Stages, 2)
assert.Equal(t, []string{"build"}, allPlan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, allPlan.Stages[1].GetJobIDs())
}
func TestCombineWorkflowPlannerMergesWorkflowStages(t *testing.T) {
first := mustReadWorkflow(t, `
name: First
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: make build
`)
second := mustReadWorkflow(t, `
name: Second
on: push
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: make lint
test:
needs: lint
runs-on: ubuntu-latest
steps:
- run: make test
`)
planner := CombineWorkflowPlanner(first, second)
plan, err := planner.PlanEvent("push")
require.NoError(t, err)
require.Len(t, plan.Stages, 2)
assert.ElementsMatch(t, []string{"build", "lint"}, plan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, plan.Stages[1].GetJobIDs())
empty, err := planner.PlanEvent("schedule")
require.NoError(t, err)
assert.Empty(t, empty.Stages)
}
func TestPlannerErrorsForMissingAndCyclicJobs(t *testing.T) {
workflow := mustReadWorkflow(t, `
name: Cyclic
on: push
jobs:
a:
needs: b
runs-on: ubuntu-latest
steps:
- run: echo a
b:
needs: a
runs-on: ubuntu-latest
steps:
- run: echo b
`)
planner := CombineWorkflowPlanner(workflow)
plan, err := planner.PlanJob("missing")
require.Error(t, err)
assert.Empty(t, plan.Stages)
assert.Contains(t, err.Error(), "Could not find any stages")
plan, err = planner.PlanEvent("push")
require.Error(t, err)
assert.Empty(t, plan.Stages)
assert.Contains(t, err.Error(), "unable to build dependency graph")
}
func TestNewSingleWorkflowPlannerErrors(t *testing.T) {
_, err := NewSingleWorkflowPlanner("empty.yml", strings.NewReader(""))
require.Error(t, err)
assert.Contains(t, err.Error(), "file is empty")
_, err = NewSingleWorkflowPlanner("invalid.yml", strings.NewReader("jobs: ["))
require.Error(t, err)
assert.Contains(t, err.Error(), "workflow is not valid")
}
func mustReadWorkflow(t *testing.T, content string) *Workflow {
t.Helper()
workflow, err := ReadWorkflow(strings.NewReader(content))
require.NoError(t, err)
if workflow.Name == "" {
workflow.Name = "workflow"
}
return workflow
}

View File

@@ -5,6 +5,7 @@
package model
import (
"fmt"
"strings"
"testing"
@@ -58,6 +59,190 @@ func TestJobNeedsResult(t *testing.T) {
}
}
func TestJobSetContinueOnErrorFirmFailureWins(t *testing.T) {
job := &Job{}
job.SetContinueOnError(true)
assert.True(t, job.ContinueOnError)
job.SetContinueOnError(false)
assert.False(t, job.ContinueOnError)
job.SetContinueOnError(true)
assert.False(t, job.ContinueOnError, "a later tolerated failure must not hide an earlier firm failure")
}
func TestStepStatusText(t *testing.T) {
for _, tc := range []struct {
status stepStatus
text string
}{
{StepStatusSuccess, "success"},
{StepStatusFailure, "failure"},
{StepStatusSkipped, "skipped"},
} {
t.Run(tc.text, func(t *testing.T) {
got, err := tc.status.MarshalText()
require.NoError(t, err)
assert.Equal(t, tc.text, string(got))
var parsed stepStatus
require.NoError(t, parsed.UnmarshalText(got))
assert.Equal(t, tc.status, parsed)
assert.Equal(t, tc.text, parsed.String())
})
}
var parsed stepStatus
require.Error(t, parsed.UnmarshalText([]byte("cancelled")))
assert.Empty(t, stepStatus(99).String())
}
func TestWorkflowCallConfig(t *testing.T) {
workflow, err := ReadWorkflow(strings.NewReader(`
on:
workflow_call:
inputs:
name:
required: true
type: string
outputs:
digest:
value: ${{ jobs.build.outputs.digest }}
jobs: {}
`))
require.NoError(t, err)
config := workflow.WorkflowCallConfig()
require.NotNil(t, config)
require.Contains(t, config.Inputs, "name")
assert.True(t, config.Inputs["name"].Required)
assert.Equal(t, "string", config.Inputs["name"].Type)
assert.Equal(t, "${{ jobs.build.outputs.digest }}", config.Outputs["digest"].Value)
listWorkflow, err := ReadWorkflow(strings.NewReader("on: [workflow_call]\njobs: {}\n"))
require.NoError(t, err)
assert.NotNil(t, listWorkflow.WorkflowCallConfig())
assert.Empty(t, listWorkflow.WorkflowCallConfig().Inputs)
}
func TestJobSecretsAndEnvironment(t *testing.T) {
inheritJob := readJob(t, `
secrets: inherit
env:
A: one
B: two
`)
assert.True(t, inheritJob.InheritSecrets())
assert.Nil(t, inheritJob.Secrets())
assert.Equal(t, map[string]string{"A": "one", "B": "two"}, inheritJob.Environment())
mappingJob := readJob(t, `
secrets:
TOKEN: ${{ secrets.TOKEN }}
`)
assert.False(t, mappingJob.InheritSecrets())
assert.Equal(t, map[string]string{"TOKEN": "${{ secrets.TOKEN }}"}, mappingJob.Secrets())
}
func TestJobTypeAndString(t *testing.T) {
tests := []struct {
job Job
want JobType
wantErr bool
}{
{job: Job{}, want: JobTypeDefault},
{job: Job{Uses: "./.github/workflows/reuse.yml"}, want: JobTypeReusableWorkflowLocal},
{job: Job{Uses: "owner/repo/.github/workflows/reuse.yaml@v1"}, want: JobTypeReusableWorkflowRemote},
{job: Job{Uses: "owner/repo/.github/workflows/reuse.yaml"}, want: JobTypeInvalid, wantErr: true},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("%s/%s", tc.job.Uses, tc.want), func(t *testing.T) {
got, err := tc.job.Type()
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.want, got)
})
}
assert.Equal(t, "default", JobTypeDefault.String())
assert.Equal(t, "local-reusable-workflow", JobTypeReusableWorkflowLocal.String())
assert.Equal(t, "remote-reusable-workflow", JobTypeReusableWorkflowRemote.String())
assert.Equal(t, "unknown", JobType(99).String())
}
func TestStepStringEnvironmentEnvAndType(t *testing.T) {
step := readStep(t, `
id: example
env:
DIRECT: value
with:
mixed-key: input
`)
assert.Equal(t, "example", step.String())
assert.Equal(t, map[string]string{"DIRECT": "value"}, step.Environment())
assert.Equal(t, map[string]string{"DIRECT": "value", "INPUT_MIXED-KEY": "input"}, step.GetEnv())
for _, tc := range []struct {
step Step
want StepType
}{
{step: Step{}, want: StepTypeInvalid},
{step: Step{Run: "echo hi"}, want: StepTypeRun},
{step: Step{Run: "echo hi", Uses: "actions/checkout@v4"}, want: StepTypeInvalid},
{step: Step{Uses: "docker://alpine:latest"}, want: StepTypeUsesDockerURL},
{step: Step{Uses: "./.github/workflows/reuse.yml"}, want: StepTypeReusableWorkflowLocal},
{step: Step{Uses: "owner/repo/.github/workflows/reuse.yml@v1"}, want: StepTypeReusableWorkflowRemote},
{step: Step{Uses: "./actions/local"}, want: StepTypeUsesActionLocal},
{step: Step{Uses: "actions/checkout@v4"}, want: StepTypeUsesActionRemote},
} {
t.Run(tc.want.String(), func(t *testing.T) {
assert.Equal(t, tc.want, tc.step.Type())
})
}
assert.Equal(t, "invalid", StepTypeInvalid.String())
assert.Equal(t, "run", StepTypeRun.String())
assert.Equal(t, "local-action", StepTypeUsesActionLocal.String())
assert.Equal(t, "remote-action", StepTypeUsesActionRemote.String())
assert.Equal(t, "docker", StepTypeUsesDockerURL.String())
assert.Equal(t, "local-reusable-workflow", StepTypeReusableWorkflowLocal.String())
assert.Equal(t, "remote-reusable-workflow", StepTypeReusableWorkflowRemote.String())
assert.Equal(t, "unknown", StepType(99).String())
assert.NotEmpty(t, (&Step{Uses: "actions/checkout@v4"}).UsesHash())
}
func TestWorkflowGetJobAndIDs(t *testing.T) {
workflow := &Workflow{Jobs: map[string]*Job{"build": {}}}
assert.Equal(t, []string{"build"}, workflow.GetJobIDs())
job := workflow.GetJob("build")
require.NotNil(t, job)
assert.Equal(t, "build", job.Name)
assert.Equal(t, "success()", job.If.Value)
assert.Nil(t, workflow.GetJob("missing"))
}
func TestRawConcurrencyYaml(t *testing.T) {
var expr RawConcurrency
require.NoError(t, yaml.Unmarshal([]byte("group-${{ github.ref }}"), &expr))
assert.Equal(t, "group-${{ github.ref }}", expr.RawExpression)
marshaled, err := expr.MarshalYAML()
require.NoError(t, err)
assert.Equal(t, "group-${{ github.ref }}", marshaled)
var object RawConcurrency
require.NoError(t, yaml.Unmarshal([]byte("group: ci\ncancel-in-progress: true\n"), &object))
assert.Equal(t, "ci", object.Group)
assert.Equal(t, "true", object.CancelInProgress)
marshaled, err = object.MarshalYAML()
require.NoError(t, err)
assert.Equal(t, (*objectConcurrency)(&object), marshaled)
}
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
yaml := `
name: local-action-docker-url
@@ -952,3 +1137,19 @@ func TestJobMatrixValidation(t *testing.T) {
assert.Nil(t, matrix, "matrix with nested map should return nil")
})
}
func readJob(t *testing.T, content string) *Job {
t.Helper()
var job Job
require.NoError(t, yaml.Unmarshal([]byte(content), &job))
return &job
}
func readStep(t *testing.T, content string) *Step {
t.Helper()
var step Step
require.NoError(t, yaml.Unmarshal([]byte(content), &step))
return &step
}

View File

@@ -405,7 +405,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
stepContainer.Start(true),
).Finally(
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
).Finally(stepContainer.Close())(ctx)
}

View File

@@ -8,6 +8,7 @@ import (
"context"
"errors"
"regexp"
"slices"
"strconv"
"strings"
@@ -85,6 +86,19 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
return compositerc
}
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
// is already present in dst. This prevents the parent RunContext's Masks slice
// from growing exponentially when composite actions are nested or repeated,
// since each composite RunContext is seeded with its parent's masks.
func appendUniqueMasks(dst, src []string) []string {
for _, m := range src {
if !slices.Contains(dst, m) {
dst = append(dst, m)
}
}
return dst
}
func execAsComposite(step actionStep) common.Executor {
rc := step.getRunContext()
action := step.getActionModel()
@@ -110,7 +124,11 @@ func execAsComposite(step actionStep) common.Executor {
}, eval.Interpolate(ctx, output.Value))
}
rc.Masks = append(rc.Masks, compositeRC.Masks...)
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
// and may have additional masks appended while the composite action runs.
// Only append masks that are not already present, otherwise nested or
// repeated composite actions grow rc.Masks exponentially.
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
rc.ExtraPath = compositeRC.ExtraPath
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
mergeIntoMap := mergeIntoMapCaseSensitive

View File

@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAppendUniqueMasks(t *testing.T) {
tests := []struct {
name string
dst []string
src []string
want []string
}{
{
name: "appends new masks",
dst: []string{"a"},
src: []string{"b", "c"},
want: []string{"a", "b", "c"},
},
{
name: "skips masks already present",
dst: []string{"a", "b"},
src: []string{"a", "b"},
want: []string{"a", "b"},
},
{
name: "deduplicates within src",
dst: []string{"a"},
src: []string{"b", "b", "a"},
want: []string{"a", "b"},
},
{
name: "empty src leaves dst unchanged",
dst: []string{"a"},
src: nil,
want: []string{"a"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, appendUniqueMasks(tt.dst, tt.src))
})
}
}
// TestAppendUniqueMasksNoExponentialGrowth reproduces the exponential growth of
// the parent's Masks slice observed with nested/repeated composite actions. A
// composite RunContext is seeded with its parent's masks and the whole seeded
// slice was previously appended back into the parent, doubling its length on
// every composite action.
func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) {
parentMasks := []string{"secret"}
for range 20 {
// compositeRC.Masks starts as a copy of the parent's masks (it is
// seeded with parent.Masks in newCompositeRunContext).
compositeMasks := make([]string, len(parentMasks))
copy(compositeMasks, parentMasks)
parentMasks = appendUniqueMasks(parentMasks, compositeMasks)
}
assert.Equal(t, []string{"secret"}, parentMasks)
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type closerMock struct {
@@ -150,6 +151,44 @@ runs:
}
}
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
func TestExecAsDockerAutoRemove(t *testing.T) {
orig := ContainerNewContainer
defer func() { ContainerNewContainer = orig }()
for _, tc := range []struct {
autoRemove bool
removes int
}{
{false, 2}, // stale + post-run
{true, 1}, // post-run skipped
} {
cm := &containerMock{}
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm }
step := &stepActionRemote{
Step: &model.Step{ID: "1", Uses: "org/action@v1"},
RunContext: &RunContext{
Config: &Config{AutoRemove: tc.autoRemove},
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
JobContainer: cm,
},
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "docker://node:14"}},
}
removes := 0
cm.On("Pull", false).Return(func(context.Context) error { return nil })
cm.On("Remove").Return(func(context.Context) error { removes++; return nil })
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
cm.On("Start", true).Return(func(context.Context) error { return nil })
cm.On("Close").Return(func(context.Context) error { return nil })
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false))
cm.AssertExpectations(t)
assert.Equal(t, tc.removes, removes)
}
}
func TestActionRunner(t *testing.T) {
table := []struct {
name string

View File

@@ -283,3 +283,30 @@ func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
require.NoError(t, postCtx.Err(), "post context must not carry the expired deadline")
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError, "the timeout job error must be preserved")
}
// reportStepError must treat a context.Canceled (e.g. a teardown-cancelled read) as an
// interruption, never a job failure.
func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) {
rc := &RunContext{}
// stray read cancellation while the job context is live: ignored, not a failure
live := common.WithJobErrorContainer(context.Background())
reportStepError(live, rc, context.Canceled)
require.NoError(t, common.JobError(live))
assert.False(t, rc.jobFailed)
assert.False(t, rc.jobCancelled)
// genuine job cancellation: recorded as cancelled, still not a failure
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
cancel()
reportStepError(cancelled, rc, context.Canceled)
require.NoError(t, common.JobError(cancelled))
assert.False(t, rc.jobFailed)
assert.True(t, rc.jobCancelled)
// a real error still fails the job
failed := common.WithJobErrorContainer(context.Background())
reportStepError(failed, rc, assert.AnError)
require.ErrorIs(t, common.JobError(failed), assert.AnError)
assert.True(t, rc.jobFailed)
}

View File

@@ -229,7 +229,8 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt
logger.Debugf("evaluating expression '%s'", in)
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%t", evaluated), "::add-mask::***)")
// evaluated is an any: %t renders everything but a bool as "%!t(string=...)"
printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)")
logger.Debugf("expression '%s' evaluated to '%s'", in, printable)
return evaluated, err
@@ -497,11 +498,7 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod
if value == nil {
value = v.Default
}
if v.Type == "boolean" {
inputs[k] = value == "true"
} else {
inputs[k] = value
}
inputs[k] = coerceInputValue(value, v.Type)
}
}
}
@@ -514,17 +511,26 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod
if value == nil {
value = v.Default
}
if v.Type == "boolean" {
inputs[k] = value == "true"
} else {
inputs[k] = value
}
inputs[k] = coerceInputValue(value, v.Type)
}
}
}
return inputs
}
// coerceInputValue converts an input value to the type declared by the workflow.
// The event payload carries natively typed JSON values on newer Gitea versions,
// while defaults and older servers provide strings.
func coerceInputValue(value any, inputType string) any {
if inputType != "boolean" {
return value
}
if b, ok := value.(bool); ok {
return b
}
return value == "true"
}
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
if rc.caller != nil {
config := rc.Run.Workflow.WorkflowCallConfig()
@@ -548,7 +554,7 @@ func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunCon
}
}
(*inputs)[name] = value
(*inputs)[name] = coerceInputValue(value, input.Type)
}
}
}

View File

@@ -6,12 +6,14 @@ package runner
import (
"context"
"strings"
"testing"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
assert "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
yaml "go.yaml.in/yaml/v4"
)
@@ -321,3 +323,82 @@ func TestRewriteSubExpressionForceFormat(t *testing.T) {
})
}
}
func TestGetEvaluatorInputsBoolean(t *testing.T) {
workflows := map[string]string{
"workflow_call": `
on:
workflow_call:
inputs:
flag:
type: boolean
default: true
name:
type: string
default: gitea
`,
"workflow_dispatch": `
on:
workflow_dispatch:
inputs:
flag:
type: boolean
default: true
name:
type: string
default: gitea
`,
}
tables := []struct {
name string
event map[string]any
flag any
}{
{
// Gitea >= 1.27 resolves the inputs server-side and sends native JSON types
name: "native bool true",
event: map[string]any{"inputs": map[string]any{"flag": true}},
flag: true,
},
{
name: "native bool false",
event: map[string]any{"inputs": map[string]any{"flag": false}},
flag: false,
},
{
name: "string true",
event: map[string]any{"inputs": map[string]any{"flag": "true"}},
flag: true,
},
{
name: "string false",
event: map[string]any{"inputs": map[string]any{"flag": "false"}},
flag: false,
},
{
name: "default is used when the event carries no inputs",
event: map[string]any{},
flag: true,
},
}
for eventName, workflow := range workflows {
for _, table := range tables {
t.Run(eventName+"/"+table.name, func(t *testing.T) {
wf, err := model.ReadWorkflow(strings.NewReader(workflow))
require.NoError(t, err)
rc := &RunContext{
Config: &Config{Workdir: "."},
Run: &model.Run{JobID: "job1", Workflow: wf},
}
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc)
assert.Equal(t, table.flag, inputs["flag"])
assert.Equal(t, "gitea", inputs["name"])
})
}
}
}

View File

@@ -10,6 +10,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -56,17 +57,81 @@ type jobInfo interface {
result(result string)
}
// reportStepError emits the GitHub Actions ##[error] annotation and records
// the error against the job so the job is reported as failed.
// reportStepError records a step error so the job is reported failed — except a
// cancellation, which is an interruption, not a failure.
func reportStepError(ctx context.Context, rc *RunContext, err error) {
if errors.Is(err, context.Canceled) {
// Defer to the job context: a genuine cancel reports cancelled, a stray teardown
// cancellation on a live ctx is ignored — never a step FAILURE.
rc.markInterrupted(ctx.Err())
return
}
common.Logger(ctx).Errorf("##[error]%v", err)
common.SetJobError(ctx, err)
rc.markFailed()
}
// actionPreparer is implemented by steps that download an action before they run, so the job
// executor can fetch all of them up front.
type actionPreparer interface {
prepareActionExecutor() common.Executor
actionDownloadInfo() (reference, sha string, ok bool)
}
// printPrepareActions downloads every action the job uses before its first step runs and reports
// them as actions/runner's "Prepare all required actions" section does. The steps still call
// prepareActionExecutor themselves; it is a no-op once the action is resolved here.
func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Executor {
return func(ctx context.Context) error {
if len(preparers) == 0 {
return nil
}
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
rawLogger.Infof("Prepare all required actions")
for _, preparer := range preparers {
if err := preparer.prepareActionExecutor()(ctx); err != nil {
// No step has run yet, so the failure belongs to the job.
reportStepError(ctx, rc, err)
return err
}
reference, sha, ok := preparer.actionDownloadInfo()
if !ok {
continue
}
if sha == "" {
rawLogger.Infof("Download action repository '%s'", reference)
} else {
rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha)
}
}
return nil
}
}
// printCompleteJobName closes the setup section the way actions/runner ends its "Set up job" step.
func printCompleteJobName(rc *RunContext) common.Executor {
return func(ctx context.Context) error {
// Name holds a matrix combination; JobName is the shared name GitHub reports.
name := rc.JobName
if name == "" {
name = rc.Name
}
if name == "" && rc.Run != nil {
name = rc.Run.JobID
}
common.Logger(ctx).WithField(rawOutputField, true).Infof("Complete job name: %s", name)
return nil
}
}
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
steps := make([]common.Executor, 0)
preSteps := make([]common.Executor, 0)
// Collected separately: every action is downloaded before the first pre step runs.
stepPreSteps := make([]common.Executor, 0)
preparers := make([]actionPreparer, 0)
var postExecutor common.Executor
steps = append(steps, func(ctx context.Context) error {
@@ -113,9 +178,13 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
return common.NewErrorExecutor(err)
}
if preparer, ok := step.(actionPreparer); ok {
preparers = append(preparers, preparer)
}
stepIdx := stepModel.Number
preExec := step.pre()
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
stepPreSteps = append(stepPreSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
rc.CurrentStepIndex = stepIdx
preErr := preExec(ctx)
if preErr != nil {
@@ -157,6 +226,11 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
}
}
// The setup section of the job log: download the actions, run the pre steps, then name the job.
preSteps = append(preSteps, printPrepareActions(rc, preparers))
preSteps = append(preSteps, stepPreSteps...)
preSteps = append(preSteps, printCompleteJobName(rc))
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
jobError := common.JobError(ctx)
var err error

View File

@@ -24,6 +24,7 @@ import (
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model"
log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -111,6 +112,182 @@ func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, er
return args.Get(0).(step), args.Error(1)
}
// actionPreparerMock stands in for a step whose action is downloaded before the job's first step.
type actionPreparerMock struct {
reference string
sha string
ok bool
err error
prepared int
}
func (apm *actionPreparerMock) prepareActionExecutor() common.Executor {
return func(context.Context) error {
apm.prepared++
return apm.err
}
}
func (apm *actionPreparerMock) actionDownloadInfo() (string, string, bool) {
return apm.reference, apm.sha, apm.ok
}
func TestPrintPrepareActionsGolden(t *testing.T) {
buf := &bytes.Buffer{}
logger := log.New()
logger.SetOutput(buf)
logger.SetLevel(log.InfoLevel)
logger.SetFormatter(&jobLogFormatter{color: cyan})
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
preparers := []actionPreparer{
&actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", ok: true},
// A resolved commit is best effort; the ref alone is reported when it is unknown.
&actionPreparerMock{reference: "actions/setup-go@v6", ok: true},
// A step that downloads nothing, such as the checkout of the workflow's own repository.
&actionPreparerMock{ok: false},
}
require.NoError(t, printPrepareActions(&RunContext{}, preparers)(ctx))
want := strings.Join([]string{
"[j1] | Prepare all required actions",
"[j1] | Download action repository 'actions/checkout@v7' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)",
"[j1] | Download action repository 'actions/setup-go@v6'",
"",
}, "\n")
assert.Equal(t, want, buf.String())
}
func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
buf := &bytes.Buffer{}
logger := log.New()
logger.SetOutput(buf)
logger.SetFormatter(&jobLogFormatter{color: cyan})
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
require.NoError(t, printPrepareActions(&RunContext{}, nil)(ctx))
assert.Empty(t, buf.String())
}
func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
logger, _ := logrustest.NewNullLogger()
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("job", "j1")))
downloadErr := errors.New("failed to fetch \"actions/checkout\"")
rc := &RunContext{}
remaining := &actionPreparerMock{reference: "actions/setup-go@v6", ok: true}
err := printPrepareActions(rc, []actionPreparer{
&actionPreparerMock{err: downloadErr},
remaining,
})(ctx)
require.ErrorIs(t, err, downloadErr)
// No step has run yet, so the failure has to be recorded against the job itself.
assert.Equal(t, downloadErr, common.JobError(ctx))
assert.True(t, rc.jobFailed)
assert.Zero(t, remaining.prepared)
}
func TestPrintCompleteJobName(t *testing.T) {
for name, tt := range map[string]struct {
rc *RunContext
want string
}{
"job name": {rc: &RunContext{JobName: "lint", Name: "lint-1"}, want: "lint"},
"falls back to name": {rc: &RunContext{Name: "lint-1"}, want: "lint-1"},
"falls back to jobID": {rc: &RunContext{Run: &model.Run{JobID: "lint"}}, want: "lint"},
} {
t.Run(name, func(t *testing.T) {
buf := &bytes.Buffer{}
logger := log.New()
logger.SetOutput(buf)
logger.SetFormatter(&jobLogFormatter{color: cyan})
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
require.NoError(t, printCompleteJobName(tt.rc)(ctx))
assert.Equal(t, "[j1] | Complete job name: "+tt.want+"\n", buf.String())
})
}
}
// actionStepMock is a step whose action has to be downloaded before it can run.
type actionStepMock struct {
*stepMock
*actionPreparerMock
}
// TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep pins the shape of the setup section:
// every action is downloaded before any step runs, and the job name closes the section. A pre
// step that downloaded its own action would leave the log interleaved with the downloads.
func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
ctx := common.WithJobErrorContainer(context.Background())
jim := &jobInfoMock{}
sfm := &stepFactoryMock{}
rc := &RunContext{
JobContainer: &jobContainerMock{},
Run: &model.Run{
JobID: "test",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{"test": {}},
},
},
Config: &Config{},
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
steps := []*model.Step{{ID: "1"}, {ID: "2"}}
executorOrder := make([]string, 0)
jim.On("steps").Return(steps)
jim.On("matrix").Return(map[string]any{})
jim.On("startContainer").Return(func(context.Context) error { return nil })
jim.On("stopContainer").Return(func(context.Context) error { return nil })
jim.On("closeContainer").Return(func(context.Context) error { return nil })
jim.On("interpolateOutputs").Return(func(context.Context) error { return nil })
jim.On("result", "success")
for _, stepModel := range steps {
sm := &stepMock{}
apm := &actionPreparerMock{reference: "actions/checkout@v" + stepModel.ID, ok: true}
sfm.On("newStep", stepModel, rc).Return(&actionStepMock{stepMock: sm, actionPreparerMock: apm}, nil)
sm.On("pre").Return(func(context.Context) error {
executorOrder = append(executorOrder, "pre"+stepModel.ID)
return nil
})
sm.On("main").Return(func(context.Context) error {
executorOrder = append(executorOrder, "step"+stepModel.ID)
return nil
})
sm.On("post").Return(func(context.Context) error { return nil })
defer sm.AssertExpectations(t)
}
logger, hook := logrustest.NewNullLogger()
err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.WithField("job", "test")))
require.NoError(t, err)
assert.Equal(t, []string{"pre1", "pre2", "step1", "step2"}, executorOrder)
setup := make([]string, 0)
for _, entry := range hook.AllEntries() {
if strings.HasPrefix(entry.Message, "Prepare all required actions") || strings.HasPrefix(entry.Message, "Download action") ||
strings.HasPrefix(entry.Message, "Complete job name") {
setup = append(setup, entry.Message)
}
}
assert.Equal(t, []string{
"Prepare all required actions",
"Download action repository 'actions/checkout@v1'",
"Download action repository 'actions/checkout@v2'",
"Complete job name: test",
}, setup)
}
func TestNewJobExecutor(t *testing.T) {
table := []struct {
name string

View File

@@ -305,30 +305,45 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executo
// getGitCloneToken returns GITEA_TOKEN when shouldCloneURLUseToken returns true,
// otherwise returns an empty string
func getGitCloneToken(conf *Config, cloneURL string) string {
if !shouldCloneURLUseToken(conf.GitHubInstance, cloneURL) {
if !shouldCloneURLUseToken(conf.GitHubInstance, conf.trustedActionInstance(), cloneURL) {
return ""
}
return conf.GetToken()
}
// For Gitea
// shouldCloneURLUseToken returns true when the following conditions are met:
// 1. cloneURL is from the same Gitea instance that the runner is registered to
// 2. the cloneURL does not have basic auth embedded
func shouldCloneURLUseToken(instanceURL, cloneURL string) bool {
if !strings.HasPrefix(instanceURL, "http://") &&
!strings.HasPrefix(instanceURL, "https://") {
instanceURL = "https://" + instanceURL
// trustedActionInstance returns the self-hosted DEFAULT_ACTIONS_URL host that may carry the
// task token, or "" when actions resolve to github.com / a GithubMirror (never trusted).
func (c Config) trustedActionInstance() string {
if c.DefaultActionInstanceIsSelfHosted {
return c.DefaultActionInstance
}
u1, err1 := url.Parse(instanceURL)
u2, err2 := url.Parse(cloneURL)
if err1 != nil || err2 != nil {
return false
}
if u2.User != nil {
return false
}
return u1.Host == u2.Host
return ""
}
// For Gitea
// shouldCloneURLUseToken returns true when the following conditions are met:
// 1. cloneURL's host matches this Gitea instance: either the registered instance
// (instanceURL) or, for DEFAULT_ACTIONS_URL=self on a different hostname, the
// self-hosted action instance (trustedActionInstance, "" when not trusted)
// 2. the cloneURL does not have basic auth embedded
func shouldCloneURLUseToken(instanceURL, trustedActionInstance, cloneURL string) bool {
u2, err := url.Parse(cloneURL)
if err != nil || u2.User != nil {
return false
}
for _, candidate := range []string{instanceURL, trustedActionInstance} {
if candidate == "" {
continue
}
if !strings.HasPrefix(candidate, "http://") &&
!strings.HasPrefix(candidate, "https://") {
candidate = "https://" + candidate
}
if u1, err := url.Parse(candidate); err == nil && u1.Host == u2.Host {
return true
}
}
return false
}

View File

@@ -136,12 +136,30 @@ func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
require.Equal(t, "token-value", token)
}
func TestGetGitCloneTokenSelfHostedActionsDifferentHost(t *testing.T) {
// The runner registered with one hostname while DEFAULT_ACTIONS_URL=self resolves
// actions against AppURL on a different hostname for the same instance.
conf := &Config{
GitHubInstance: "gitea.local",
DefaultActionInstance: "https://gitea.my-nas.lan",
DefaultActionInstanceIsSelfHosted: true,
Secrets: map[string]string{
"GITEA_TOKEN": "token-value",
},
}
token := getGitCloneToken(conf, "https://gitea.my-nas.lan/owner/action")
require.Equal(t, "token-value", token)
}
func TestShouldCloneURLUseToken(t *testing.T) {
tests := []struct {
name string
instanceURL string
cloneURL string
want bool
name string
instanceURL string
trustedActionInstance string
cloneURL string
want bool
}{
{
name: "same host with schemaless instance",
@@ -173,11 +191,37 @@ func TestShouldCloneURLUseToken(t *testing.T) {
cloneURL: "://gitea.example.net/actions/tools",
want: false,
},
{
// self-hosted DEFAULT_ACTIONS_URL on a different hostname than the
// registered instance: the token must still be attached.
name: "self-hosted action instance on different host",
instanceURL: "gitea.local",
trustedActionInstance: "https://gitea.my-nas.lan",
cloneURL: "https://gitea.my-nas.lan/owner/action",
want: true,
},
{
// embedded basic auth must still be rejected even when the host matches
// the trusted action instance.
name: "self-hosted action instance with embedded basic auth",
instanceURL: "gitea.local",
trustedActionInstance: "https://gitea.my-nas.lan",
cloneURL: "https://user:pass@gitea.my-nas.lan/owner/action",
want: false,
},
{
// github.com / mirror hosts are never trusted: trustedActionInstance is
// empty in github mode, so an off-instance clone URL gets no token.
name: "github mode does not trust mirror host",
instanceURL: "gitea.local",
cloneURL: "https://mirror.example.com/owner/action",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.cloneURL))
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.trustedActionInstance, tt.cloneURL))
})
}
}

View File

@@ -138,7 +138,9 @@ func (rc *RunContext) GetEnv() map[string]string {
}
}
}
rc.Env["ACT"] = "true"
if !rc.Config.DisableActEnv {
rc.Env["ACT"] = "true"
}
if !rc.Config.NoSkipCheckout {
rc.Env["ACT_SKIP_CHECKOUT"] = "true"
@@ -337,6 +339,9 @@ func printStartJobContainerGroup(ctx context.Context, image, name, network strin
}
}
// newContainer is a variable so tests can substitute a container that needs no Docker daemon.
var newContainer = container.NewContainer
func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
@@ -379,6 +384,13 @@ func (rc *RunContext) startJobContainer() common.Executor {
// add service containers
for serviceID, spec := range rc.Run.Job().Services {
// GitHub compatibility: skip services whose image evaluates to an
// empty string, enabling conditional services via expressions
serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image)
if serviceImage == "" {
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue
}
// interpolate env
interpolatedEnvs := make(map[string]string, len(spec.Env))
for k, v := range spec.Env {
@@ -393,7 +405,9 @@ func (rc *RunContext) startJobContainer() common.Executor {
for _, v := range spec.Cmd {
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v))
}
username, password, err = rc.handleServiceCredentials(ctx, spec.Credentials)
// keep these local: reusing username/password would overwrite the
// credentials the job container is pulled with further down
serviceUsername, servicePassword, err := rc.handleServiceCredentials(ctx, spec.Credentials)
if err != nil {
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
}
@@ -414,12 +428,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
}
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := container.NewContainer(&container.NewContainerInput{
c := newContainer(&container.NewContainerInput{
Name: serviceContainerName,
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: rc.ExprEval.Interpolate(ctx, spec.Image),
Username: username,
Password: password,
Image: serviceImage,
Username: serviceUsername,
Password: servicePassword,
Cmd: interpolatedCmd,
Env: envs,
Mounts: serviceMounts,
@@ -440,42 +454,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
rc.ServiceContainers = append(rc.ServiceContainers, c)
}
rc.cleanUpJobContainer = func(ctx context.Context) error {
reuseJobContainer := func(ctx context.Context) bool {
return rc.Config.ReuseContainers
}
if rc.JobContainer != nil {
return rc.JobContainer.Remove().IfNot(reuseJobContainer).
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName(), false)).IfNot(reuseJobContainer).
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName()+"-env", false)).IfNot(reuseJobContainer).
Then(func(ctx context.Context) error {
if len(rc.ServiceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName)
if err := rc.stopServiceContainers()(ctx); err != nil {
logger.Errorf("Error while cleaning services: %v", err)
}
}
if createAndDeleteNetwork {
// clean network if it has been created by act
// if using service containers
// it means that the network to which containers are connecting is created by `runner`,
// so, we should remove the network at last.
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
logger.Errorf("Error while cleaning network: %v", err)
}
}
return nil
})(ctx)
}
return nil
}
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName
rc.JobContainer = container.NewContainer(&container.NewContainerInput{
rc.JobContainer = newContainer(&container.NewContainerInput{
Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
@@ -525,6 +509,41 @@ func (rc *RunContext) startJobContainer() common.Executor {
}
}
// cleanupJobResources removes everything the job created, continuing past failures.
// Only job container and volume errors are returned, the rest are logged.
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
removeJobContainer := rc.JobContainer != nil && !rc.Config.ReuseContainers
var errs []error
if removeJobContainer {
errs = append(errs, rc.JobContainer.Remove()(ctx))
}
if len(rc.ServiceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName)
if err := rc.stopServiceContainers()(ctx); err != nil {
logger.Errorf("Error while cleaning services: %v", err)
}
}
if removeJobContainer {
// after the containers using them, services can hold these via `--volumes-from`
name := rc.jobContainerName()
errs = append(errs,
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
}
if createAndDeleteNetwork {
// last, once every container has detached
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
logger.Errorf("Error while cleaning network: %v", err)
}
}
return errors.Join(errs...)
}
}
func (rc *RunContext) execJobContainer(cmd []string, env map[string]string, user, workdir string) common.Executor { //nolint:unparam // pre-existing issue from nektos/act
return func(ctx context.Context) error {
return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx)
@@ -783,7 +802,13 @@ func (rc *RunContext) Executor() (common.Executor, error) {
return func(ctx context.Context) error {
res, err := rc.isEnabled(ctx)
if err != nil {
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure") // For Gitea
// Record the failure so a job whose if-expression fails to evaluate
// gets a result (and therefore a stop time) instead of being left
// unfinished. rc.caller is only set for reusable workflows.
rc.result("failure")
if rc.caller != nil { // For Gitea
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure")
}
return err
}
if res {

View File

@@ -7,6 +7,7 @@ package runner
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"runtime"
@@ -14,6 +15,7 @@ import (
"testing"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
@@ -202,6 +204,93 @@ jobs:
assert.Empty(t, password)
}
// fakeContainer turns every container operation into a no-op, so startJobContainer
// runs without a Docker daemon. The embedded interface is nil, so any method the
// test does not exercise panics rather than silently doing the wrong thing.
type fakeContainer struct {
container.ExecutionsEnvironment
}
func (fakeContainer) Pull(bool) common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) Start(bool) common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) GetActPath() string { return "/var/run/act" }
func (fakeContainer) Create([]string, []string) common.Executor {
return func(context.Context) error { return nil }
}
func (fakeContainer) Copy(string, ...*container.FileEntry) common.Executor {
return func(context.Context) error { return nil }
}
// Regression test: a service without a `credentials:` block resolves to empty
// credentials, which used to overwrite the job container's own credentials.
func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(`
name: test
on: push
jobs:
job:
runs-on: ubuntu-latest
container:
image: registry.example/private:latest
credentials:
username: job-user
password: job-password
services:
redis:
image: redis:latest
db:
image: postgres:latest
credentials:
username: db-user
password: db-password
steps: []
`))
require.NoError(t, err)
var inputs []*container.NewContainerInput
origNewContainer := newContainer
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
inputs = append(inputs, input)
return fakeContainer{}
}
t.Cleanup(func() { newContainer = origNewContainer })
rc := &RunContext{
Name: "test",
Config: &Config{
Workdir: "/tmp",
// no daemon: an explicit network mode creates no network, and
// reusing containers short-circuits the volume cleanup executors
ContainerNetworkMode: "host",
ReuseContainers: true,
Env: map[string]string{},
Secrets: map[string]string{},
},
Env: map[string]string{},
Run: &model.Run{
JobID: "job",
Workflow: workflow,
},
}
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
require.NoError(t, rc.startJobContainer()(t.Context()))
credentials := map[string][2]string{}
for _, in := range inputs {
credentials[in.Image] = [2]string{in.Username, in.Password}
}
// the job container keeps its own credentials, whichever services exist
require.Equal(t, [2]string{"job-user", "job-password"}, credentials["registry.example/private:latest"])
// each service keeps its own, and a service without credentials gets none
require.Equal(t, [2]string{"db-user", "db-password"}, credentials["postgres:latest"])
require.Equal(t, [2]string{"", ""}, credentials["redis:latest"])
}
func TestRunContext_GetBindsAndMounts(t *testing.T) {
rctemplate := &RunContext{
Name: "TestRCName",
@@ -363,6 +452,46 @@ func TestRunContextValidVolumes(t *testing.T) {
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
}
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
service := &containerMock{}
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
service.On("Close").Return(func(context.Context) error { return nil }).Once()
rc := &RunContext{
Config: &Config{},
ServiceContainers: []container.ExecutionsEnvironment{service},
}
err := rc.cleanupJobResources("external-network", false)(context.Background())
require.NoError(t, err)
service.AssertExpectations(t)
}
// cleanup used to bail out on a previous step's error and on a cancelled context
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
jobContainer := &containerMock{}
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
service := &containerMock{}
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
service.On("Close").Return(func(context.Context) error { return nil }).Once()
rc := &RunContext{
Name: "job",
Config: &Config{},
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
JobContainer: jobContainer,
ServiceContainers: []container.ExecutionsEnvironment{service},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx))
jobContainer.AssertExpectations(t)
service.AssertExpectations(t)
}
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
// combo's resolved value freezes the shared template and later combos can't resolve their own.

View File

@@ -65,6 +65,7 @@ type Config struct {
ArtifactServerAddr string // the address the artifact server binds to
ArtifactServerPort string // the port the artifact server binds to
NoSkipCheckout bool // do not skip actions/checkout
DisableActEnv bool // do not inject the ACT=true environment variable into jobs
RemoteName string // remote name in local git repo config
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
@@ -73,18 +74,24 @@ type Config struct {
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
ActionCache ActionCache // Use a custom ActionCache Implementation
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
ContainerNamePrefix string // the prefix of container name
ContainerMaxLifetime time.Duration // the max lifetime of job containers
CleanWorkdir bool // remove host executor workdir on teardown
DefaultActionInstance string // the default actions web site
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
JobLoggerLevel *log.Level // the level of job logger
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
AllocatePTY bool // allocate a pseudo-TTY for each step's process
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
ContainerNamePrefix string // the prefix of container name
ContainerMaxLifetime time.Duration // the max lifetime of job containers
CleanWorkdir bool // remove host executor workdir on teardown
DefaultActionInstance string // the default actions web site
// DefaultActionInstanceIsSelfHosted reports whether DefaultActionInstance is this
// self-hosted Gitea (DEFAULT_ACTIONS_URL=self). It gates token trust: only then may the
// task token be attached to action clone URLs on DefaultActionInstance's host, which can
// differ from GitHubInstance when the runner registered with a different hostname than
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
DefaultActionInstanceIsSelfHosted bool
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
JobLoggerLevel *log.Level // the level of job logger
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
AllocatePTY bool // allocate a pseudo-TTY for each step's process
}
// GetToken: Adapt to Gitea

View File

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

View File

@@ -107,7 +107,13 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if strings.Contains(stepString, "::add-mask::") {
stepString = "add-mask command"
}
logger.Infof("Run %s %s", stage, stepString)
if stage == stepStageMain {
// Main steps print their own raw "Run <title>" header, so this line is redundant and
// only leaks into the "Set up job" section for the first step; keep it as a debug trace.
logger.Debugf("Run %s %s", stage, stepString)
} else {
logger.Infof("Run %s %s", stage, stepString)
}
// Prepare and clean Runner File Commands
actPath := rc.JobContainer.GetActPath()

View File

@@ -121,7 +121,7 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
token := ""
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, cloneURL) {
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
token = github.Token
}
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
@@ -131,6 +131,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
Token: token,
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
Depth: sar.RunContext.Config.ActionCloneDepth,
// printPrepareActions reports the download with its resolved commit.
Quiet: true,
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
})
@@ -146,6 +148,13 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
}
}
// Best effort: the download report falls back to the ref alone when the commit is unknown.
if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil {
common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err)
} else {
sar.resolvedSha = sha
}
remoteReader := func(ctx context.Context) actionYamlReader { //nolint:unparam // pre-existing issue from nektos/act
return func(filename string) (io.Reader, io.Closer, error) {
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
@@ -165,6 +174,15 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
}
}
// actionDownloadInfo reports the action this step downloaded and the commit it resolved to. ok is
// false when nothing was fetched, as for the local checkout of the workflow's own repository.
func (sar *stepActionRemote) actionDownloadInfo() (reference, sha string, ok bool) {
if sar.remoteAction == nil || sar.action == nil {
return "", "", false
}
return sar.remoteAction.Reference(), sar.resolvedSha, true
}
func (sar *stepActionRemote) pre() common.Executor {
sar.env = map[string]string{}
@@ -313,6 +331,16 @@ func (ra *remoteAction) CloneURL(u string) string {
return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo)
}
// Reference renders the action as {org}/{repo}[/path]@{ref}, omitting the download source, which
// can be interpolated from a secret.
func (ra *remoteAction) Reference() string {
repo := fmt.Sprintf("%s/%s", ra.Org, ra.Repo)
if ra.Path != "" {
repo = fmt.Sprintf("%s/%s", repo, ra.Path)
}
return fmt.Sprintf("%s@%s", repo, ra.Ref)
}
func (ra *remoteAction) IsCheckout() bool {
if ra.Org == "actions" && ra.Repo == "checkout" {
return true

View File

@@ -10,6 +10,9 @@ import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
@@ -818,6 +821,97 @@ func Test_newRemoteAction(t *testing.T) {
}
}
func Test_remoteActionReference(t *testing.T) {
tests := []struct {
uses string
want string
}{
{uses: "actions/checkout@v7", want: "actions/checkout@v7"},
{uses: "actions/aws/ec2@main", want: "actions/aws/ec2@main"},
// The download source can be interpolated from a secret and must stay out of the log.
{uses: "https://gitea.example.com/actions/checkout@v7", want: "actions/checkout@v7"},
}
for _, tt := range tests {
t.Run(tt.uses, func(t *testing.T) {
assert.Equal(t, tt.want, newRemoteAction(tt.uses).Reference())
})
}
}
// TestStepActionRemotePreResolvesDownloadedCommit runs the real download path against a local
// git repository standing in for the actions instance, so the reported commit is the one the
// clone actually checked out.
func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
instance := t.TempDir()
actionDir := filepath.Join(instance, "actions", "setup-go")
require.NoError(t, os.MkdirAll(actionDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(actionDir, "action.yml"),
[]byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600))
// Supply an identity on the commit so the test does not depend on a
// git identity being configured in the environment; a CI runner without
// user.name/user.email would otherwise fail "commit" with exit code 128.
for _, args := range [][]string{
{"init", "--initial-branch=main", actionDir},
{"-C", actionDir, "add", "action.yml"},
{"-C", actionDir, "-c", "user.name=runner", "-c", "user.email=runner@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "action"},
} {
cmd := exec.Command("git", args...)
require.NoError(t, cmd.Run(), "git %v", args)
}
out, err := exec.Command("git", "-C", actionDir, "rev-parse", "HEAD").Output()
require.NoError(t, err)
wantSha := strings.TrimSpace(string(out))
sar := &stepActionRemote{
Step: &model.Step{Uses: "actions/setup-go@main"},
RunContext: &RunContext{
Config: &Config{
GitHubInstance: "https://gitea.example.com",
DefaultActionInstance: instance,
ActionCacheDir: t.TempDir(),
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}},
},
},
readAction: readActionImpl,
}
require.NoError(t, sar.prepareActionExecutor()(context.Background()))
reference, sha, ok := sar.actionDownloadInfo()
assert.True(t, ok)
assert.Equal(t, "actions/setup-go@main", reference)
assert.Equal(t, wantSha, sha)
}
func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
t.Run("reports the action and its resolved commit", func(t *testing.T) {
sar := &stepActionRemote{
remoteAction: newRemoteAction("actions/checkout@v7"),
action: &model.Action{},
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
}
reference, sha, ok := sar.actionDownloadInfo()
assert.True(t, ok)
assert.Equal(t, "actions/checkout@v7", reference)
assert.Equal(t, "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", sha)
})
t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
// The local checkout of the workflow's own repository resolves no action.
sar := &stepActionRemote{remoteAction: newRemoteAction("actions/checkout@v7")}
_, _, ok := sar.actionDownloadInfo()
assert.False(t, ok)
})
}
func Test_safeFilename(t *testing.T) {
tests := []struct {
s string

View File

@@ -85,7 +85,7 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
stepContainer.Start(true),
).Finally(
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
).Finally(stepContainer.Close())(ctx)
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestStepDockerMain(t *testing.T) {
@@ -118,6 +119,43 @@ func TestStepDockerMain(t *testing.T) {
cm.AssertExpectations(t)
}
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
func TestStepDockerAutoRemove(t *testing.T) {
orig := ContainerNewContainer
defer func() { ContainerNewContainer = orig }()
for _, tc := range []struct {
autoRemove bool
removes int
}{
{false, 2}, // stale + post-run
{true, 1}, // post-run skipped
} {
cm := &containerMock{}
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm }
sd := &stepDocker{
RunContext: &RunContext{
Config: &Config{AutoRemove: tc.autoRemove},
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
JobContainer: cm,
},
Step: &model.Step{ID: "1", Uses: "docker://node:14"},
}
removes := 0
cm.On("Pull", false).Return(func(context.Context) error { return nil })
cm.On("Remove").Return(func(context.Context) error { removes++; return nil })
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
cm.On("Start", true).Return(func(context.Context) error { return nil })
cm.On("Close").Return(func(context.Context) error { return nil })
require.NoError(t, sd.runUsesContainer()(context.Background()))
cm.AssertExpectations(t)
assert.Equal(t, tc.removes, removes)
}
}
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
for _, tc := range []struct {
name string

View File

@@ -1 +1 @@
FROM ubuntu:24.04
FROM ubuntu:26.04

View File

@@ -0,0 +1,10 @@
name: services-empty-image
on: push
jobs:
test:
runs-on: ubuntu-latest
services:
db:
image: ${{ false && 'postgres:16' || '' }}
steps:
- run: echo "empty-image service was skipped"

View File

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

View File

@@ -6,6 +6,11 @@ NOTE: `dind-docker.yaml` uses the native sidecar pattern (init container with `r
NOTE: A helm chart for `gitea-runner` also exists for easier deployments https://gitea.com/gitea/helm-actions
Each example persists **two** things, and it is worth knowing which is which:
- `/data` is the runner's working directory. It holds the `.runner` registration file and, optionally, the config file — so the runner re-attaches to the server instead of registering again.
- The Docker daemon's data root holds the images pulled for jobs (`/var/lib/docker` for the dind sidecar, `/home/rootless/.local/share/docker` for `dind-rootless`). It is *not* under `/data`. If you drop this volume, the examples still work, but the image cache is discarded whenever the pod is recreated and every job re-pulls its images.
Files in this directory:
- [`dind-docker.yaml`](dind-docker.yaml)
@@ -13,3 +18,6 @@ Files in this directory:
- [`rootless-docker.yaml`](rootless-docker.yaml)
How to create a rootless Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
- [`statefulset-dind.yaml`](statefulset-dind.yaml)
StatefulSet variant of the dind example. Each replica gets a stable identity and its own persistent volume via `volumeClaimTemplates`, so the runner keeps its `.runner` registration across restarts and reschedules instead of trying to register again.

View File

@@ -1,3 +1,5 @@
# Holds the runner's working directory (/data): the .runner registration file
# and, optionally, the config file.
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
@@ -10,6 +12,21 @@ spec:
storage: 1Gi
storageClassName: standard
---
# Holds the Docker daemon's data root (/var/lib/docker), i.e. the images pulled
# for jobs. Without it, the image cache is lost whenever the pod is recreated
# and every job re-pulls its images. Size it for the images you expect to cache.
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: docker-vol
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
storageClassName: standard
---
apiVersion: v1
data:
# The registration token can be obtained from the web UI, API or command-line.
@@ -45,6 +62,9 @@ spec:
- name: runner-data
persistentVolumeClaim:
claimName: runner-vol
- name: docker-data
persistentVolumeClaim:
claimName: docker-vol
initContainers:
- name: docker
image: docker:28.2.2-dind
@@ -53,6 +73,8 @@ spec:
volumeMounts:
- name: docker-socket
mountPath: /var/run
- name: docker-data
mountPath: /var/lib/docker
startupProbe:
exec:
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]

View File

@@ -1,3 +1,5 @@
# Holds the runner's working directory (/data): the .runner registration file
# and, optionally, the config file.
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
@@ -10,6 +12,21 @@ spec:
storage: 1Gi
storageClassName: standard
---
# Holds the rootless Docker daemon's data root, i.e. the images pulled for jobs.
# Without it, the image cache is lost whenever the pod is recreated and every job
# re-pulls its images. Size it for the images you expect to cache.
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: docker-vol
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
storageClassName: standard
---
apiVersion: v1
data:
# The registration token can be obtained from the web UI, API or command-line.
@@ -43,7 +60,12 @@ spec:
- name: runner-data
persistentVolumeClaim:
claimName: runner-vol
- name: docker-data
persistentVolumeClaim:
claimName: docker-vol
securityContext:
# The dind-rootless image runs as the `rootless` user (UID/GID 1000);
# fsGroup makes both volumes writable for it.
fsGroup: 1000
containers:
- name: runner
@@ -68,4 +90,7 @@ spec:
volumeMounts:
- name: runner-data
mountPath: /data
# The rootless daemon keeps its images here, not under /data.
- name: docker-data
mountPath: /home/rootless/.local/share/docker

View File

@@ -0,0 +1,96 @@
# StatefulSet variant of the dind example.
#
# Unlike the Deployment, a StatefulSet gives each replica a stable identity and,
# via volumeClaimTemplates, its own persistent volume. That means every runner
# pod keeps its own `.runner` registration file across restarts and reschedules,
# so it re-attaches to the server instead of trying to register again.
apiVersion: v1
data:
# The registration token can be obtained from the web UI, API or command-line.
# You can also set a pre-defined global runner registration token for the Gitea instance via
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
token: << base64 encoded registration token >>
kind: Secret
metadata:
name: runner-secret
type: Opaque
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
app: runner
name: runner
spec:
serviceName: runner
replicas: 1
selector:
matchLabels:
app: runner
template:
metadata:
labels:
app: runner
spec:
restartPolicy: Always
volumes:
- name: docker-socket
emptyDir: {}
initContainers:
- name: docker
image: docker:28.2.2-dind
securityContext:
privileged: true
volumeMounts:
- name: docker-socket
mountPath: /var/run
# Keeps the images pulled for jobs across restarts. Without this, the
# daemon's data root is ephemeral and every job re-pulls its images.
- name: docker-data
mountPath: /var/lib/docker
startupProbe:
exec:
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
livenessProbe:
exec:
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
restartPolicy: Always
containers:
- name: runner
image: gitea/runner:nightly
env:
- name: GITEA_INSTANCE_URL
value: http://gitea-http.gitea.svc.cluster.local:3000
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:
name: runner-secret
key: token
volumeMounts:
- name: runner-data
mountPath: /data
- name: docker-socket
mountPath: /var/run
volumeClaimTemplates:
# The runner's working directory: the .runner registration file and, optionally,
# the config file.
- metadata:
name: runner-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: standard
# The Docker daemon's data root: the images pulled for jobs. Size it for the
# images you expect to cache.
- metadata:
name: docker-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
storageClassName: standard

View File

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

View File

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

34
go.mod
View File

@@ -11,7 +11,7 @@ require (
github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24
github.com/distribution/reference v0.6.0
github.com/docker/cli v29.6.0+incompatible
github.com/docker/cli v29.6.2+incompatible
github.com/docker/go-connections v0.7.0
github.com/go-git/go-billy/v5 v5.9.0
github.com/go-git/go-git/v5 v5.19.1
@@ -20,7 +20,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/julienschmidt/httprouter v1.3.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-isatty v0.0.22
github.com/mattn/go-isatty v0.0.23
github.com/moby/go-archive v0.2.0
github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.5.0
@@ -28,7 +28,8 @@ require (
github.com/opencontainers/image-spec v1.1.1
github.com/opencontainers/selinux v1.15.1
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_golang v1.24.0
github.com/prometheus/client_model v0.6.2
github.com/rhysd/actionlint v1.7.12
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
@@ -36,9 +37,10 @@ require (
github.com/stretchr/testify v1.11.1
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
go.etcd.io/bbolt v1.5.0
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/text v0.40.0
google.golang.org/protobuf v1.36.11
gotest.tools/v3 v3.5.2
tags.cncf.io/container-device-interface v1.1.0
@@ -71,7 +73,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
@@ -84,9 +86,8 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
@@ -97,16 +98,15 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

76
go.sum
View File

@@ -47,10 +47,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+4uf10QRSc=
github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw=
github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
@@ -104,8 +102,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -119,8 +117,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
@@ -129,12 +127,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
@@ -161,14 +155,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=
github.com/rhysd/actionlint v1.7.12/go.mod h1:krOUhujIsJusovkaYzQ/VNH8PFexjNKqU0q5XI/4w+g=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -213,8 +207,6 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
@@ -222,34 +214,34 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -258,14 +250,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

View File

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

View File

@@ -50,7 +50,7 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
secret := cfg.Cache.ExternalSecret
if secret == "" {
return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
return errors.New("cache.external_secret (or cache.external_secret_file) must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
}
cacheHandler, err := artifactcache.StartHandler(
dir,

View File

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

View File

@@ -49,10 +49,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
return fmt.Errorf("failed to load registration file: %w", err)
}
lbls := reg.Labels
if len(cfg.Runner.Labels) > 0 {
lbls = cfg.Runner.Labels
}
lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels)
ls := labels.Labels{}
for _, l := range lbls {
@@ -150,17 +147,19 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
}
runner.SetCapabilitiesFromDeclare(resp)
poller := poll.New(cfg, cli, runner)
if cfg.Metrics.Enabled {
metrics.Init()
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
metrics.RegisterUptimeFunc(time.Now())
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
metrics.StartServer(ctx, cfg.Metrics.Addr)
metrics.StartServer(ctx, cfg.Metrics.Addr, func() (bool, string) {
return poller.Ready(cfg.Metrics.ReadinessGrace)
})
}
poller := poll.New(cfg, cli, runner)
if daemArgs.Once || reg.Ephemeral {
done := make(chan struct{})
go func() {
@@ -176,7 +175,12 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
} else {
go poller.Poll()
<-ctx.Done()
// Stop either on an external cancellation or when the poller shuts
// itself down (e.g. after the runner has been unregistered).
select {
case <-ctx.Done():
case <-poller.Done():
}
}
log.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Msg.Runner.Name, cfg.Runner.ShutdownTimeout)
@@ -189,12 +193,39 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
}
if poller.Unregistered() {
return errors.New("runner is no longer registered with the server; please register it again")
}
return nil
}
}
type daemonArgs struct {
Once bool
Once bool
Labels string
}
// resolveLabels picks the labels to run with: --labels/GITEA_RUNNER_LABELS > config > .runner.
// The flag lets a registered runner change its labels without deleting the .runner file.
func resolveLabels(argLabels string, cfgLabels, regLabels []string) []string {
if lbls := splitLabels(argLabels); len(lbls) > 0 {
return lbls
}
if len(cfgLabels) > 0 {
return cfgLabels
}
return regLabels
}
func splitLabels(s string) []string {
var lbls []string
for l := range strings.SplitSeq(s, ",") {
if l = strings.TrimSpace(l); l != "" {
lbls = append(lbls, l)
}
}
return lbls
}
// initLogging setup the global logrus logger.

View File

@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"testing"
"gitea.com/gitea/runner/internal/pkg/config"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
)
func TestResolveLabels(t *testing.T) {
var (
cfgLabels = []string{"cfg:host"}
regLabels = []string{"reg:host"}
)
tests := []struct {
name string
arg string
cfg []string
reg []string
want []string
}{
{"flag wins", "flag:host,other", cfgLabels, regLabels, []string{"flag:host", "other"}},
{"config wins over registration", "", cfgLabels, regLabels, cfgLabels},
{"registration is the fallback", "", nil, regLabels, regLabels},
{"blank flag is ignored", " , ", cfgLabels, regLabels, cfgLabels},
{"nothing configured", "", nil, nil, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, resolveLabels(tt.arg, tt.cfg, tt.reg))
})
}
}
func TestGetDockerSocketPathUsesConfigAndEnvironment(t *testing.T) {
got, err := getDockerSocketPath("tcp://docker.example:2376")
require.NoError(t, err)
require.Equal(t, "tcp://docker.example:2376", got)
t.Setenv("DOCKER_HOST", "unix:///tmp/docker.sock")
got, err = getDockerSocketPath("-")
require.NoError(t, err)
require.Equal(t, "unix:///tmp/docker.sock", got)
}
func TestInitLoggingSetsLevelAndCaller(t *testing.T) {
oldLevel := log.GetLevel()
oldReportCaller := log.StandardLogger().ReportCaller
t.Cleanup(func() {
log.SetLevel(oldLevel)
log.SetReportCaller(oldReportCaller)
})
cfg := &config.Config{}
cfg.Log.Level = "debug"
initLogging(cfg)
require.Equal(t, log.DebugLevel, log.GetLevel())
require.True(t, log.StandardLogger().ReportCaller)
}

View File

@@ -34,6 +34,7 @@ type executeArgs struct {
runList bool
job string
event string
eventpath string
workdir string
workflowsPath string
noWorkflowRecurse bool
@@ -441,12 +442,14 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
ArtifactServerPort: execArgs.artifactServerPort,
ArtifactServerAddr: execArgs.artifactServerAddr,
NoSkipCheckout: execArgs.noSkipCheckout,
EventPath: execArgs.resolve(execArgs.eventpath),
// PresetGitHubContext: preset,
// EventJSON: string(eventJSON),
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(execArgs.network),
DefaultActionInstance: execArgs.defaultActionsURL,
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(execArgs.network),
DefaultActionInstance: execArgs.defaultActionsURL,
DefaultActionInstanceIsSelfHosted: execArgs.defaultActionsURL != "" && execArgs.defaultActionsURL != "https://github.com",
PlatformPicker: func(_ []string) string {
return execArgs.image
},
@@ -495,8 +498,9 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
}
execCmd.Flags().BoolVarP(&execArg.runList, "list", "l", false, "list workflows")
execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID")
execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID; when several workflow files define that job, also pass --workflows/-W to select the file")
execCmd.Flags().StringVarP(&execArg.event, "event", "E", "", "run a event name")
execCmd.Flags().StringVarP(&execArg.eventpath, "eventpath", "e", "", "path to a JSON event payload file exposed as the event that triggered the workflow")
execCmd.PersistentFlags().StringVarP(&execArg.workflowsPath, "workflows", "W", "./.gitea/workflows/", "path to workflow file(s)")
execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "working directory")
execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "Flag to disable running workflows from subdirectories of specified path in '--workflows'/'-W' flag")

View File

@@ -0,0 +1,220 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
func TestExecuteArgsResolve(t *testing.T) {
workdir := t.TempDir()
args := &executeArgs{workdir: workdir}
require.Empty(t, args.resolve(""))
require.Equal(t, filepath.Join(workdir, "sub", "file"), args.resolve("sub/file"))
abs := filepath.Join(workdir, "abs")
require.Equal(t, abs, args.resolve(abs))
}
func TestExecuteArgsPaths(t *testing.T) {
workdir := t.TempDir()
args := &executeArgs{
workdir: workdir,
workflowsPath: ".gitea/workflows",
envfile: ".env",
}
require.Equal(t, filepath.Join(workdir, ".gitea/workflows"), args.WorkflowsPath())
require.Equal(t, filepath.Join(workdir, ".env"), args.Envfile())
require.Equal(t, workdir, args.Workdir())
}
func TestExecuteArgsLoadVars(t *testing.T) {
require.Empty(t, (&executeArgs{}).LoadVars())
args := &executeArgs{vars: []string{"FOO=bar", "EMPTY", "WITH=eq=sign"}}
require.Equal(t, map[string]string{
"FOO": "bar",
"EMPTY": "",
"WITH": "eq=sign",
}, args.LoadVars())
}
func TestExecuteArgsLoadSecrets(t *testing.T) {
t.Setenv("FROMENV", "from-env-value")
args := &executeArgs{secrets: []string{"token=abc", "fromenv"}}
require.Equal(t, map[string]string{
"TOKEN": "abc",
"FROMENV": "from-env-value",
}, args.LoadSecrets())
}
func TestReadEnvs(t *testing.T) {
dir := t.TempDir()
envFile := filepath.Join(dir, ".env")
require.NoError(t, os.WriteFile(envFile, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
envs := map[string]string{"EXISTING": "keep"}
require.True(t, readEnvs(envFile, envs))
require.Equal(t, map[string]string{
"EXISTING": "keep",
"FOO": "bar",
"BAZ": "qux",
}, envs)
missing := map[string]string{}
require.False(t, readEnvs(filepath.Join(dir, "does-not-exist"), missing))
require.Empty(t, missing)
}
func TestRunExecListUsesJobEventAndAllPlans(t *testing.T) {
planner := &fakeWorkflowPlanner{
events: []string{"push", "pull_request"},
plans: map[string]*model.Plan{
"job:build": listPlan("build", "Build", "push"),
"event:push": listPlan("test", "Test", "push"),
"all": listPlan("lint", "Lint", "push"),
},
}
out := captureStdout(t, func() {
require.NoError(t, runExecList(planner, &executeArgs{job: "build"}))
require.NoError(t, runExecList(planner, &executeArgs{event: "push"}))
require.NoError(t, runExecList(planner, &executeArgs{autodetectEvent: true}))
require.NoError(t, runExecList(planner, &executeArgs{}))
})
require.Contains(t, out, "Build")
require.Contains(t, out, "Test")
require.Contains(t, out, "Lint")
require.Equal(t, []string{"job:build", "event:push", "event:push", "all"}, planner.calls)
}
func TestPrintListReportsDuplicateJobIDs(t *testing.T) {
workflowA := workflowForList("A", "a.yml", "push", "build", "Build A")
workflowB := workflowForList("B", "b.yml", "pull_request", "build", "Build B")
plan := &model.Plan{Stages: []*model.Stage{{
Runs: []*model.Run{
{Workflow: workflowA, JobID: "build"},
{Workflow: workflowB, JobID: "build"},
},
}}}
out := captureStdout(t, func() {
printList(plan)
})
require.Contains(t, out, "Workflow file")
require.Contains(t, out, "Build A")
require.Contains(t, out, "Build B")
require.Contains(t, out, "Detected multiple jobs with the same job name")
}
func TestLoadExecCmdDefinesExpectedFlags(t *testing.T) {
cmd := loadExecCmd(context.Background())
for _, name := range []string{
"list",
"job",
"event",
"workflows",
"directory",
"env",
"secret",
"var",
"dryrun",
"image",
"gitea-instance",
} {
if cmd.Flags().Lookup(name) == nil && cmd.PersistentFlags().Lookup(name) == nil {
t.Fatalf("expected flag %q to be registered", name)
}
}
require.Equal(t, "exec", cmd.Use)
require.NoError(t, cmd.Args(cmd, strings.Split("a b c", " ")))
require.Error(t, cmd.Args(cmd, strings.Fields(strings.Repeat("arg ", 21))))
}
type fakeWorkflowPlanner struct {
events []string
plans map[string]*model.Plan
calls []string
}
func (p *fakeWorkflowPlanner) PlanEvent(eventName string) (*model.Plan, error) {
p.calls = append(p.calls, "event:"+eventName)
return p.plans["event:"+eventName], nil
}
func (p *fakeWorkflowPlanner) PlanJob(jobName string) (*model.Plan, error) {
p.calls = append(p.calls, "job:"+jobName)
return p.plans["job:"+jobName], nil
}
func (p *fakeWorkflowPlanner) PlanAll() (*model.Plan, error) {
p.calls = append(p.calls, "all")
return p.plans["all"], nil
}
func (p *fakeWorkflowPlanner) GetEvents() []string {
return p.events
}
func listPlan(jobID, jobName, event string) *model.Plan {
workflow := workflowForList("Workflow "+jobID, jobID+".yml", event, jobID, jobName)
return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: jobID}}}}}
}
func workflowForList(name, file, event, jobID, jobName string) *model.Workflow {
return &model.Workflow{
Name: name,
File: file,
RawOn: rawOnNode(event),
Jobs: map[string]*model.Job{
jobID: {Name: jobName},
},
}
}
func rawOnNode(event string) yaml.Node {
var node yaml.Node
if err := yaml.Unmarshal([]byte(event), &node); err != nil {
panic(err)
}
return *node.Content[0]
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
fn()
require.NoError(t, w.Close())
os.Stdout = old
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
require.NoError(t, err)
require.NoError(t, r.Close())
return buf.String()
}

View File

@@ -75,6 +75,7 @@ type registerArgs struct {
NoInteractive bool
InstanceAddr string
Token string
TokenFile string
RunnerName string
Labels string
Ephemeral bool
@@ -93,6 +94,8 @@ const (
StageExit
)
const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN"
var defaultLabels = []string{
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
@@ -207,10 +210,27 @@ func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *co
return StageUnknown
}
func initInputs(regArgs *registerArgs) *registerInputs {
func initInputs(regArgs *registerArgs) (*registerInputs, error) {
var token string
switch {
case regArgs.TokenFile != "":
tokenBytes, err := os.ReadFile(regArgs.TokenFile)
if err != nil {
return nil, fmt.Errorf("cannot read the token file: %s, %v", regArgs.TokenFile, err)
}
token = string(tokenBytes)
case regArgs.Token != "":
token = regArgs.Token
default:
envToken, ok := os.LookupEnv(registerTokenEnvVar)
if !ok || envToken == "" {
return nil, fmt.Errorf("missing token, token-file argument, or %s environment variable", registerTokenEnvVar)
}
token = envToken
}
inputs := &registerInputs{
InstanceAddr: regArgs.InstanceAddr,
Token: regArgs.Token,
Token: token,
RunnerName: regArgs.RunnerName,
Ephemeral: regArgs.Ephemeral,
}
@@ -219,7 +239,7 @@ func initInputs(regArgs *registerArgs) *registerInputs {
if regArgs.Labels != "" {
inputs.Labels = strings.Split(regArgs.Labels, ",")
}
return inputs
return inputs, nil
}
func registerInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error {
@@ -235,7 +255,10 @@ func registerInteractive(ctx context.Context, configFile string, regArgs *regist
if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() {
stage = StageOverwriteLocalConfig
}
inputs := initInputs(regArgs)
inputs, err := initInputs(regArgs)
if err != nil {
return err
}
for {
cmdString := inputs.stageValue(stage)
@@ -292,7 +315,10 @@ func registerNoInteractive(ctx context.Context, configFile string, regArgs *regi
if err != nil {
return err
}
inputs := initInputs(regArgs)
inputs, err := initInputs(regArgs)
if err != nil {
return err
}
// specify labels in config file.
if len(cfg.Runner.Labels) > 0 {
if regArgs.Labels != "" {
@@ -361,7 +387,10 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
ls := make([]string, len(reg.Labels))
for i, v := range reg.Labels {
l, _ := labels.Parse(v)
l, err := labels.Parse(v)
if err != nil {
return fmt.Errorf("failed to parse label %q: %w", v, err)
}
ls[i] = l.Name
}
// register new runner.

View File

@@ -4,16 +4,245 @@
package cmd
import (
"os"
"testing"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/require"
"gotest.tools/v3/assert"
)
func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
err := registerNoInteractive(t.Context(), "", &registerArgs{
Labels: "label:invalid",
Labels: "ubuntu:host,,broken",
Token: "token",
InstanceAddr: "http://localhost:3000",
})
assert.Error(t, err, "unsupported schema: invalid")
assert.ErrorContains(t, err, "empty label")
}
func TestRegisterInputsValidate(t *testing.T) {
tests := []struct {
name string
inputs registerInputs
wantErr string
}{
{
name: "empty instance address",
inputs: registerInputs{Token: "token"},
wantErr: "instance address is empty",
},
{
name: "empty token",
inputs: registerInputs{InstanceAddr: "http://localhost:3000"},
wantErr: "token is empty",
},
{
name: "invalid label",
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{""}},
wantErr: "empty label",
},
{
name: "valid",
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:host"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.inputs.validate()
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
func TestValidateLabels(t *testing.T) {
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
// a colon that is not a supported schema is part of the label name
require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
}
func TestRegisterInputsStageValue(t *testing.T) {
inputs := &registerInputs{
InstanceAddr: "http://localhost:3000",
Token: "token",
RunnerName: "runner",
Labels: []string{"ubuntu:host", "ubuntu:docker://node:18"},
}
require.Equal(t, "http://localhost:3000", inputs.stageValue(StageInputInstance))
require.Equal(t, "token", inputs.stageValue(StageInputToken))
require.Equal(t, "runner", inputs.stageValue(StageInputRunnerName))
require.Equal(t, "ubuntu:host,ubuntu:docker://node:18", inputs.stageValue(StageInputLabels))
require.Empty(t, (&registerInputs{}).stageValue(StageInputLabels))
require.Empty(t, inputs.stageValue(StageWaitingForRegistration))
}
func TestRegisterInputsAssignToNext(t *testing.T) {
emptyCfg := &config.Config{}
t.Run("instance and token stay on empty value", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputInstance, inputs.assignToNext(StageInputInstance, "", emptyCfg))
require.Equal(t, StageInputToken, inputs.assignToNext(StageInputToken, "", emptyCfg))
})
t.Run("instance then token then runner name", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputToken, inputs.assignToNext(StageInputInstance, "http://localhost:3000", emptyCfg))
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, StageInputRunnerName, inputs.assignToNext(StageInputToken, "token", emptyCfg))
require.Equal(t, "token", inputs.Token)
})
t.Run("empty runner name falls back to hostname", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputRunnerName, "", emptyCfg))
hostname, _ := os.Hostname()
require.Equal(t, hostname, inputs.RunnerName)
})
t.Run("labels from config skip the labels stage", func(t *testing.T) {
cfg := &config.Config{}
cfg.Runner.Labels = []string{"ubuntu:host", "", "pool:e57e18d4"}
inputs := &registerInputs{}
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg))
require.Equal(t, []string{"ubuntu:host", "pool:e57e18d4"}, inputs.Labels)
})
t.Run("blank labels input uses defaults", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputLabels, "", emptyCfg))
require.Equal(t, defaultLabels, inputs.Labels)
})
t.Run("invalid labels input loops back", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputLabels, "ubuntu:host,,bad", emptyCfg))
require.Nil(t, inputs.Labels)
})
t.Run("labels containing a colon are accepted", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputLabels, "pool:e57e18d4,ubuntu:host", emptyCfg))
require.Equal(t, []string{"pool:e57e18d4", "ubuntu:host"}, inputs.Labels)
})
t.Run("overwrite local config", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg))
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "y", emptyCfg))
require.Equal(t, StageExit, inputs.assignToNext(StageOverwriteLocalConfig, "n", emptyCfg))
})
t.Run("unknown stage", func(t *testing.T) {
inputs := &registerInputs{}
require.Equal(t, StageUnknown, inputs.assignToNext(StageWaitingForRegistration, "x", emptyCfg))
})
}
func TestInitInputs(t *testing.T) {
t.Run("missing token", func(t *testing.T) {
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
})
t.Run("empty token", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "")
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
Token: "",
TokenFile: "",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
})
t.Run("invalid token file", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
TokenFile: "/tmp/nonexistent",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.EqualError(t, err, "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory")
})
t.Run("valid token", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
inputs, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
Token: "from-plain-arg",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "from-plain-arg", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
t.Run("valid token file", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
tokenFile, createErr := os.CreateTemp(t.TempDir(), "from-file")
require.NoError(t, createErr)
defer tokenFile.Close()
_, writeErr := tokenFile.WriteString("from-file")
require.NoError(t, writeErr)
_ = tokenFile.Sync()
inputs, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
TokenFile: tokenFile.Name(),
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "from-file", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
t.Run("token from environment variable", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
inputs, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "from-env", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
t.Run("empty labels", func(t *testing.T) {
inputs, _ := initInputs(&registerArgs{
Token: "from-plain-arg",
Labels: " ",
})
require.Nil(t, inputs.Labels)
})
}

View File

@@ -32,6 +32,12 @@ type IdleRunner interface {
OnIdle(ctx context.Context)
}
// AvailabilityRunner can temporarily pause task fetching for local resource
// conditions such as low disk space without changing server-side scheduling.
type AvailabilityRunner interface {
CanAcceptTask(ctx context.Context) (bool, string)
}
type Poller struct {
client client.Client
runner TaskRunner
@@ -45,6 +51,15 @@ type Poller struct {
shutdownJobs context.CancelFunc
done chan struct{}
// unregistered is set when the server rejects the runner with an
// Unauthenticated response, meaning the runner is no longer registered.
unregistered atomic.Bool
lastHealthyPoll atomic.Int64
lastPollFailed atomic.Bool
availabilityMu sync.Mutex
availabilityReady bool
availabilityReason string
}
// workerState holds the single poller's backoff state. Consecutive empty or
@@ -66,7 +81,7 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
done := make(chan struct{})
return &Poller{
p := &Poller{
client: client,
runner: runner,
cfg: cfg,
@@ -79,6 +94,10 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
done: done,
}
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.availabilityReady = true
p.availabilityReason = "ok"
return p
}
func (p *Poller) Poll() {
@@ -98,6 +117,17 @@ func (p *Poller) Poll() {
return
}
ready, reason := p.localAvailability(p.pollingCtx)
p.reportAvailability(ready, reason)
if !ready {
p.runIdleMaintenance()
<-sem
if !p.waitBackoff(s) {
return
}
continue
}
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
p.runIdleMaintenance()
@@ -123,6 +153,15 @@ func (p *Poller) PollOnce() {
defer close(p.done)
s := &workerState{}
for {
ready, reason := p.localAvailability(p.pollingCtx)
p.reportAvailability(ready, reason)
if !ready {
p.runIdleMaintenance()
if !p.waitBackoff(s) {
return
}
continue
}
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
p.runIdleMaintenance()
@@ -137,6 +176,61 @@ func (p *Poller) PollOnce() {
}
}
// Done returns a channel that is closed once polling has fully stopped,
// allowing callers to react when the poller shuts itself down (e.g. after the
// runner has been unregistered) rather than only on an external cancellation.
func (p *Poller) Done() <-chan struct{} {
return p.done
}
// Unregistered reports whether polling stopped because the server rejected the
// runner as unregistered (an Unauthenticated response).
func (p *Poller) Unregistered() bool {
return p.unregistered.Load()
}
// Ready reports whether the daemon can currently communicate with Gitea and
// accept work, reusing the availability the poll loop last observed rather than
// re-running the check. Transient transport failures are tolerated for grace.
func (p *Poller) Ready(grace time.Duration) (bool, string) {
if p.unregistered.Load() {
return false, "runner is no longer registered"
}
p.availabilityMu.Lock()
ready, reason := p.availabilityReady, p.availabilityReason
p.availabilityMu.Unlock()
if !ready {
return false, reason
}
if !p.lastPollFailed.Load() {
return true, "ok"
}
if time.Since(time.Unix(0, p.lastHealthyPoll.Load())) <= grace {
return true, "polling errors within grace period"
}
return false, "unable to poll Gitea"
}
func (p *Poller) localAvailability(ctx context.Context) (bool, string) {
if available, ok := p.runner.(AvailabilityRunner); ok {
return available.CanAcceptTask(ctx)
}
return true, "ok"
}
func (p *Poller) reportAvailability(ready bool, reason string) {
p.availabilityMu.Lock()
defer p.availabilityMu.Unlock()
switch {
case !ready && p.availabilityReady:
log.Warnf("runner temporarily unavailable: %s", reason)
case ready && !p.availabilityReady:
log.Info("runner local health recovered, resuming task polling")
}
p.availabilityReady = ready
p.availabilityReason = reason
}
func (p *Poller) runIdleMaintenance() {
if idleRunner, ok := p.runner.(IdleRunner); ok {
idleRunner.OnIdle(p.jobsCtx)
@@ -256,6 +350,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
// found no work within FetchTimeout. Treat it as an empty response and do
// not record the duration — the timeout value would swamp the histogram.
if errors.Is(err, context.DeadlineExceeded) {
p.markHealthyPoll()
s.consecutiveEmpty++
s.consecutiveErrors = 0 // timeout is a healthy idle response
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
@@ -264,12 +359,23 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
if err != nil {
// An Unauthenticated response means the server no longer knows this
// runner (e.g. it was deleted). Retrying forever is pointless, so stop
// polling and let the daemon exit with an error instead of spinning.
if connect.CodeOf(err) == connect.CodeUnauthenticated {
log.WithError(err).Error("server rejected the runner as unregistered, stopping poller")
p.unregistered.Store(true)
p.shutdownPolling()
return nil, false
}
log.WithError(err).Error("failed to fetch task")
p.lastPollFailed.Store(true)
s.consecutiveErrors++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask).Inc()
return nil, false
}
p.markHealthyPoll()
// Successful response — reset error counter.
s.consecutiveErrors = 0
@@ -296,3 +402,8 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc()
return resp.Msg.Task, true
}
func (p *Poller) markHealthyPoll() {
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.lastPollFailed.Store(false)
}

View File

@@ -78,6 +78,35 @@ func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
assert.Equal(t, int64(0), s.consecutiveEmpty)
}
// TestPoller_FetchUnauthenticatedStopsPolling verifies that an Unauthenticated
// response marks the runner as unregistered and cancels the polling context so
// the daemon can exit instead of retrying forever.
func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
return nil, connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner"))
},
)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
p := New(cfg, client, nil)
s := &workerState{}
_, ok := p.fetchTask(context.Background(), s)
require.False(t, ok)
assert.True(t, p.Unregistered(), "runner should be marked unregistered")
assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff")
select {
case <-p.pollingCtx.Done():
default:
t.Fatal("expected polling context to be cancelled after an Unauthenticated response")
}
}
// TestPoller_CalculateInterval verifies the exponential backoff math is
// correctly driven by the workerState counters.
func TestPoller_CalculateInterval(t *testing.T) {
@@ -130,6 +159,81 @@ type idleAwareRunner struct {
idleCalls atomic.Int64
}
type availabilityRunner struct {
mockRunner
ready atomic.Bool
reason string
}
func (r *availabilityRunner) CanAcceptTask(_ context.Context) (bool, string) {
if r.ready.Load() {
return true, "ok"
}
return false, r.reason
}
func TestPollerReady(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
poller := New(cfg, nil, &availabilityRunner{})
// /readyz reuses the availability the poll loop last recorded.
ready, reason := poller.Ready(time.Second)
assert.True(t, ready)
assert.Equal(t, "ok", reason)
poller.reportAvailability(false, "low disk space")
ready, reason = poller.Ready(time.Second)
assert.False(t, ready)
assert.Equal(t, "low disk space", reason)
poller.reportAvailability(true, "ok")
poller.lastPollFailed.Store(true)
poller.lastHealthyPoll.Store(time.Now().UnixNano())
ready, _ = poller.Ready(time.Second)
assert.True(t, ready, "transient polling errors should remain ready during grace")
poller.lastHealthyPoll.Store(time.Now().Add(-2 * time.Second).UnixNano())
ready, reason = poller.Ready(time.Second)
assert.False(t, ready)
assert.Equal(t, "unable to poll Gitea", reason)
poller.unregistered.Store(true)
ready, reason = poller.Ready(time.Second)
assert.False(t, ready)
assert.Equal(t, "runner is no longer registered", reason)
}
func TestPollerPausesAndResumesForLocalAvailability(t *testing.T) {
var fetches atomic.Int64
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Maybe().Run(func(mock.Arguments) {
fetches.Add(1)
}).Return(connect_go.NewResponse(&runnerv1.FetchTaskResponse{}), nil)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.FetchInterval = 10 * time.Millisecond
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
runner := &availabilityRunner{reason: "low disk space"}
poller := New(cfg, cli, runner)
var wg sync.WaitGroup
wg.Go(poller.Poll)
time.Sleep(40 * time.Millisecond)
assert.Zero(t, fetches.Load(), "an unavailable runner must not fetch a task")
runner.ready.Store(true)
require.Eventually(t, func() bool {
return fetches.Load() > 0
}, time.Second, 10*time.Millisecond, "polling should resume after local recovery")
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, poller.Shutdown(shutdownCtx))
wg.Wait()
}
func (m *mockRunner) Run(ctx context.Context, _ *runnerv1.Task) error {
atomicMax(&m.maxConcurrent, m.running.Add(1))
select {

View File

@@ -0,0 +1,12 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
package run
import "fmt"
func freeDiskBytes(path string) (uint64, error) {
return 0, fmt.Errorf("free disk space checks are not supported for %s", path)
}

View File

@@ -0,0 +1,53 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"testing"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCanAcceptTaskDiskGuard(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Host.WorkdirParent = t.TempDir()
cfg.HealthCheck.Enabled = true
r := &Runner{cfg: cfg}
ready, _ := r.CanAcceptTask(t.Context())
assert.True(t, ready)
cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40
ready, reason := r.CanAcceptTask(t.Context())
assert.False(t, ready)
assert.Contains(t, reason, "low disk space")
}
func TestDiskCheckDeferredWhileJobRuns(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.HealthCheck.Enabled = true
cfg.HealthCheck.MinFreeDiskSpaceMB = 1
cfg.Host.WorkdirParent = t.TempDir()
r := &Runner{cfg: cfg}
ready, reason := r.CanAcceptTask(t.Context())
assert.True(t, ready)
assert.Equal(t, "ok", reason)
cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40
r.runningCount.Store(1)
ready, reason = r.CanAcceptTask(t.Context())
assert.True(t, ready, "the disk check must not run while a job is active")
assert.Equal(t, "ok", reason)
r.runningCount.Store(0)
ready, reason = r.CanAcceptTask(t.Context())
assert.False(t, ready)
assert.Contains(t, reason, "low disk space")
}

View File

@@ -0,0 +1,16 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package run
import "golang.org/x/sys/unix"
func freeDiskBytes(path string) (uint64, error) {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {
return 0, err
}
return uint64(stat.Bavail) * uint64(stat.Bsize), nil //nolint:unconvert // Bavail/Bsize signedness differs by platform
}

View File

@@ -0,0 +1,20 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build windows
package run
import "golang.org/x/sys/windows"
func freeDiskBytes(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var available uint64
if err := windows.GetDiskFreeSpaceEx(pathPtr, &available, nil, nil); err != nil {
return 0, err
}
return available, nil
}

View File

@@ -0,0 +1,106 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"context"
"errors"
"fmt"
"maps"
"os"
"os/exec"
"strconv"
"strings"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/internal/pkg/process"
)
func (r *Runner) checkConfiguredHealth(ctx context.Context) (bool, string) {
script := r.cfg.HealthCheck.Script
if script == "" {
return true, "ok"
}
now := time.Now()
if r.now != nil {
now = r.now()
}
if !r.healthCheckLast.IsZero() && now.Sub(r.healthCheckLast) < r.cfg.HealthCheck.Interval {
return r.healthCheckReady, r.healthCheckReason
}
env := processEnvironment()
maps.Copy(env, r.cloneEnvs())
env["GITEA_RUNNER_HEALTH_CHECK"] = "true"
env["GITEA_RUNNER_NAME"] = r.name
if r.client != nil {
env["GITEA_INSTANCE_URL"] = r.client.Address()
}
env["GITEA_RUNNER_RUNNING_JOBS"] = strconv.FormatInt(r.RunningCount(), 10)
runner := r.runHealthCheck
if runner == nil {
runner = executeHealthCheck
}
err := runner(ctx, script, r.cfg.HealthCheck.Timeout, env)
r.healthCheckLast = now
r.healthCheckReady = err == nil
if err != nil {
r.healthCheckReason = "runner health check failed: " + err.Error()
} else {
r.healthCheckReason = "ok"
}
return r.healthCheckReady, r.healthCheckReason
}
func executeHealthCheck(ctx context.Context, script string, timeout time.Duration, env map[string]string) error {
checkCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(checkCtx, script)
cmd.Env = envListFromMap(env)
cmd.SysProcAttr = process.SysProcAttr(script, false)
writer := common.NewLineWriter(func(line string) bool {
line = strings.TrimRight(line, "\r\n")
if line != "" {
common.Logger(ctx).Infof("health check: %s", line)
}
return true
})
cmd.Stdout = writer
cmd.Stderr = writer
treeKill := process.NewTreeKill(cmd)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if killer, err := treeKill.Capture(cmd.Process); err == nil {
defer killer.Close()
}
err := cmd.Wait()
common.FlushWriter(writer)
if errors.Is(checkCtx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("timed out after %s", timeout)
}
if err == nil {
return nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return fmt.Errorf("exited with code %d", exitErr.ExitCode())
}
return err
}
func processEnvironment() map[string]string {
environ := os.Environ()
env := make(map[string]string, len(environ))
for _, value := range environ {
if key, item, ok := strings.Cut(value, "="); ok {
env[key] = item
}
}
return env
}

View File

@@ -0,0 +1,148 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"context"
"errors"
"testing"
"time"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfiguredHealthCheckCachesAndRecovers(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.HealthCheck.Enabled = true
cfg.HealthCheck.Script = "/health-check"
cfg.HealthCheck.Interval = time.Minute
cfg.HealthCheck.Timeout = time.Second
now := time.Now()
calls := 0
fail := true
r := &Runner{
cfg: cfg,
name: "runner-1",
now: func() time.Time { return now },
runHealthCheck: func(_ context.Context, script string, timeout time.Duration, env map[string]string) error {
calls++
assert.Equal(t, "/health-check", script)
assert.Equal(t, time.Second, timeout)
assert.Equal(t, "true", env["GITEA_RUNNER_HEALTH_CHECK"])
assert.Equal(t, "runner-1", env["GITEA_RUNNER_NAME"])
if fail {
return errors.New("unhealthy")
}
return nil
},
}
ready, reason := r.CanAcceptTask(t.Context())
assert.False(t, ready)
assert.Contains(t, reason, "unhealthy")
assert.Equal(t, 1, calls)
fail = false
ready, _ = r.CanAcceptTask(t.Context())
assert.False(t, ready, "the failed result should remain cached")
assert.Equal(t, 1, calls)
now = now.Add(time.Minute)
ready, reason = r.CanAcceptTask(t.Context())
assert.True(t, ready)
assert.Equal(t, "ok", reason)
assert.Equal(t, 2, calls)
}
func TestConfiguredHealthCheckDisabled(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40
cfg.HealthCheck.Script = "/must-not-run"
r := &Runner{
cfg: cfg,
runHealthCheck: func(_ context.Context, _ string, _ time.Duration, _ map[string]string) error {
t.Fatal("disabled health check executed")
return nil
},
}
ready, reason := r.CanAcceptTask(t.Context())
assert.True(t, ready)
assert.Equal(t, "ok", reason)
}
func TestConfiguredHealthCheckDeferredWhileJobRuns(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.HealthCheck.Enabled = true
cfg.HealthCheck.Script = "/health-check"
cfg.HealthCheck.Interval = time.Minute
now := time.Now()
calls := 0
fail := false
r := &Runner{
cfg: cfg,
now: func() time.Time { return now },
runHealthCheck: func(_ context.Context, _ string, _ time.Duration, _ map[string]string) error {
calls++
if fail {
return errors.New("unhealthy")
}
return nil
},
}
ready, reason := r.CanAcceptTask(t.Context())
assert.True(t, ready)
assert.Equal(t, "ok", reason)
assert.Equal(t, 1, calls)
now = now.Add(time.Minute)
fail = true
r.runningCount.Store(1)
ready, reason = r.CanAcceptTask(t.Context())
assert.True(t, ready, "the last result should be reused while a job runs")
assert.Equal(t, "ok", reason)
assert.Equal(t, 1, calls)
r.runningCount.Store(0)
ready, reason = r.CanAcceptTask(t.Context())
assert.False(t, ready)
assert.Contains(t, reason, "unhealthy")
assert.Equal(t, 2, calls)
}
func TestConfiguredHealthCheckInitialRunDeferredWhileJobRuns(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.HealthCheck.Enabled = true
cfg.HealthCheck.Script = "/health-check"
calls := 0
r := &Runner{
cfg: cfg,
runHealthCheck: func(_ context.Context, _ string, _ time.Duration, _ map[string]string) error {
calls++
return nil
},
}
r.runningCount.Store(1)
ready, reason := r.CanAcceptTask(t.Context())
assert.True(t, ready)
assert.Contains(t, reason, "deferred")
assert.Zero(t, calls)
r.runningCount.Store(0)
ready, reason = r.CanAcceptTask(t.Context())
assert.True(t, ready)
assert.Equal(t, "ok", reason)
assert.Equal(t, 1, calls)
}

View File

@@ -66,6 +66,13 @@ type Runner struct {
runningCount atomic.Int64
lastIdleCleanupUnixNano atomic.Int64
now func() time.Time
healthCheckLast time.Time
healthCheckReady bool
healthCheckReason string
healthStatusSet bool
healthStatusReady bool
healthStatusReason string
runHealthCheck func(context.Context, string, time.Duration, map[string]string) error
}
func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) *Runner {
@@ -82,6 +89,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
if cfg.Cache.ExternalServer != "" {
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
} else {
warnIgnoredCacheSecret(cfg)
handler, err := artifactcache.StartHandler(
cfg.Cache.Dir,
cfg.Cache.Host,
@@ -109,13 +117,14 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
envs["GITEA_ACTIONS_RUNNER_VERSION"] = ver.Version()
runner := &Runner{
name: reg.Name,
cfg: cfg,
client: cli,
labels: ls,
envs: envs,
cacheHandler: cacheHandler,
now: time.Now,
name: reg.Name,
cfg: cfg,
client: cli,
labels: ls,
envs: envs,
cacheHandler: cacheHandler,
now: time.Now,
runHealthCheck: executeHealthCheck,
}
return runner
}
@@ -299,6 +308,15 @@ func (r *Runner) getDefaultActionsURL(task *runnerv1.Task) string {
return giteaDefaultActionsURL
}
// isSelfHostedActionsURL reports whether actions resolve to this self-hosted Gitea
// (DEFAULT_ACTIONS_URL=self), i.e. gitea_default_actions_url is AppURL rather than
// github.com (which may be mirror-substituted by getDefaultActionsURL). Only then may the
// task token be attached to action clone URLs on the actions instance host.
func (r *Runner) isSelfHostedActionsURL(task *runnerv1.Task) bool {
giteaDefaultActionsURL := task.Context.Fields["gitea_default_actions_url"].GetStringValue()
return giteaDefaultActionsURL != "" && giteaDefaultActionsURL != "https://github.com"
}
func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.Reporter) (err error) {
defer func() {
if r := recover(); r != nil {
@@ -306,7 +324,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
}
}()
reporter.Logf("%s(version:%s) received task %v of job %v, be triggered by event: %s", r.name, ver.Version(), task.Id, task.Context.Fields["job"].GetStringValue(), task.Context.Fields["event_name"].GetStringValue())
r.reportSetup(reporter, task)
workflow, jobID, err := generateWorkflow(task)
if err != nil {
@@ -436,6 +454,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
AutoRemove: true,
NoSkipCheckout: true,
DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv,
PresetGitHubContext: preset,
EventJSON: string(eventJSON),
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
@@ -446,14 +465,15 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4,
EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6,
},
ContainerOptions: r.cfg.Container.Options,
ContainerDaemonSocket: r.cfg.Container.DockerHost,
Privileged: r.cfg.Container.Privileged,
DefaultActionInstance: r.getDefaultActionsURL(task),
PlatformPicker: r.labels.PickPlatform,
Vars: task.Vars,
ValidVolumes: r.cfg.Container.ValidVolumes,
InsecureSkipTLS: r.cfg.Runner.Insecure,
ContainerOptions: r.cfg.Container.Options,
ContainerDaemonSocket: r.cfg.Container.DockerHost,
Privileged: r.cfg.Container.Privileged,
DefaultActionInstance: r.getDefaultActionsURL(task),
DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task),
PlatformPicker: r.labels.PickPlatform,
Vars: task.Vars,
ValidVolumes: r.cfg.Container.ValidVolumes,
InsecureSkipTLS: r.cfg.Runner.Insecure,
}
rr, err := runner.New(runnerConfig)
@@ -493,14 +513,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// function the caller must invoke (typically via defer) to revoke the
// credential when the task finishes.
//
// Three modes:
// Two modes:
// - Embedded handler: register in-process via RegisterJob.
// - external_server + external_secret: POST to the remote server's
// /_internal/register, defer a POST to /_internal/revoke. This is what
// enables full per-job auth and repo scoping over the network.
// - external_server alone (no secret): no-op revoker. The remote server is
// in legacy openMode and ignores the runtime token; trust is at the
// network layer.
// - external_server: POST to the remote server's /_internal/register, defer a
// POST to /_internal/revoke. This is what enables full per-job auth and
// repo scoping over the network.
//
// Safe with an empty token (older Gitea did not issue one).
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
@@ -513,6 +530,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
return r.registerExternalCacheJob(token, repo, reporter)
}
// No cache server to register against: caching is disabled, or the built-in server failed to start.
return func() {}
}
@@ -568,6 +586,67 @@ func (r *Runner) RunningCount() int64 {
return r.runningCount.Load()
}
// CanAcceptTask checks local admission conditions without consuming a task. It is
// called only from the poll loop, so the cached health fields need no lock.
func (r *Runner) CanAcceptTask(ctx context.Context) (bool, string) {
if !r.cfg.HealthCheck.Enabled {
return true, "ok"
}
if r.RunningCount() > 0 {
if !r.healthStatusSet {
return true, "health checks deferred while jobs are running"
}
return r.healthStatusReady, r.healthStatusReason
}
if ready, reason := checkFreeDisk(r.cfg); !ready {
r.setHealthStatus(ready, reason)
return false, reason
}
ready, reason := r.checkConfiguredHealth(ctx)
r.setHealthStatus(ready, reason)
return ready, reason
}
func (r *Runner) setHealthStatus(ready bool, reason string) {
r.healthStatusSet = true
r.healthStatusReady = ready
r.healthStatusReason = reason
}
// checkFreeDisk evaluates the configured task-admission disk threshold.
func checkFreeDisk(cfg *config.Config) (bool, string) {
root := cfg.Host.WorkdirParent
if cfg.Container.BindWorkdir {
root = filepath.FromSlash("/" + strings.TrimLeft(cfg.Container.WorkdirParent, "/"))
}
root = nearestExistingPath(root)
available, err := freeDiskBytes(root)
if err != nil {
return false, fmt.Sprintf("cannot determine free disk space for %s: %v", root, err)
}
availableMB := available / (1024 * 1024)
if availableMB < uint64(cfg.HealthCheck.MinFreeDiskSpaceMB) {
return false, fmt.Sprintf("low disk space on %s: %d MiB available, %d MiB required", root, availableMB, cfg.HealthCheck.MinFreeDiskSpaceMB)
}
return true, "ok"
}
func nearestExistingPath(path string) string {
for path != "" {
if _, err := os.Stat(path); err == nil {
return path
}
parent := filepath.Dir(path)
if parent == path {
return parent
}
path = parent
}
return "."
}
func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Response[runnerv1.DeclareResponse], error) {
return r.client.Declare(ctx, connect.NewRequest(&runnerv1.DeclareRequest{
Version: ver.Version(),
@@ -575,3 +654,20 @@ func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Respons
Capabilities: RunnerCapabilities(),
}))
}
// warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server.
func warnIgnoredCacheSecret(cfg *config.Config) {
if cfg.Cache.ExternalServer != "" {
return
}
// Not using an external cache server, so any configured secret is ignored.
if cfg.Cache.ExternalSecret == "" {
return
}
// LoadDefault resolves external_secret_file into ExternalSecret, so report whichever key the operator actually wrote.
key := "cache.external_secret"
if cfg.Cache.ExternalSecretFile != "" {
key = "cache.external_secret_file"
}
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
}

View File

@@ -0,0 +1,104 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"context"
"testing"
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
func TestRunnerCapabilitiesAndDeclare(t *testing.T) {
require.Equal(t, []string{CapabilityCancelling}, RunnerCapabilities())
cli := clientmocks.NewClient(t)
cli.On("Declare", mock.Anything, mock.MatchedBy(func(req *connect.Request[runnerv1.DeclareRequest]) bool {
return req.Msg.Version == ver.Version() &&
len(req.Msg.Labels) == 1 &&
req.Msg.Labels[0] == "ubuntu" &&
len(req.Msg.Capabilities) == 1 &&
req.Msg.Capabilities[0] == CapabilityCancelling
})).Return(connect.NewResponse(&runnerv1.DeclareResponse{}), nil)
r := &Runner{client: cli}
_, err := r.Declare(context.Background(), []string{"ubuntu"})
require.NoError(t, err)
}
func TestRunnerSetCapabilitiesFromDeclare(t *testing.T) {
r := &Runner{}
r.SetCapabilitiesFromDeclare(nil)
require.Empty(t, r.capabilities)
resp := connect.NewResponse(&runnerv1.DeclareResponse{})
resp.Header().Set("X-Gitea-Actions-Capabilities", " cancelling,cache-v2 ")
r.SetCapabilitiesFromDeclare(resp)
require.Equal(t, "cancelling,cache-v2", r.capabilities)
}
func TestRunnerDefaultActionsURLUsesMirrorOnlyForGithub(t *testing.T) {
r := &Runner{cfg: &config.Config{}}
r.cfg.Runner.GithubMirror = "https://mirror.example"
task := taskWithDefaultActionsURL("https://github.com")
require.Equal(t, "https://mirror.example", r.getDefaultActionsURL(task))
task = taskWithDefaultActionsURL("https://gitea.example")
require.Equal(t, "https://gitea.example", r.getDefaultActionsURL(task))
}
func TestRunnerRunningCountAndNullLogger(t *testing.T) {
r := &Runner{}
require.Equal(t, int64(0), r.RunningCount())
r.runningCount.Add(2)
require.Equal(t, int64(2), r.RunningCount())
logger := NullLogger{}.WithJobLogger()
require.NotNil(t, logger)
require.NotNil(t, logger.Out)
}
func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
cacheEnabled := false
cfg := &config.Config{}
cfg.Cache.Enabled = &cacheEnabled
cfg.Runner.Envs = map[string]string{"EXISTING": "value"}
reg := &config.Registration{
Name: "runner",
Labels: []string{"ubuntu:host", "", "pool:e57e18d4"},
}
cli := clientmocks.NewClient(t)
cli.On("Address").Return("https://gitea.example/").Maybe()
r := NewRunner(cfg, reg, cli)
require.Equal(t, "runner", r.name)
require.Len(t, r.labels, 2)
require.Equal(t, []string{"ubuntu", "pool:e57e18d4"}, r.labels.Names())
require.Equal(t, "value", r.envs["EXISTING"])
require.Equal(t, "https://gitea.example/api/actions_pipeline/", r.envs["ACTIONS_RUNTIME_URL"])
require.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
require.Equal(t, "true", r.envs["GITEA_ACTIONS"])
require.NotEmpty(t, r.envs["GITEA_ACTIONS_RUNNER_VERSION"])
require.Nil(t, r.cacheHandler)
}
func taskWithDefaultActionsURL(url string) *runnerv1.Task {
return &runnerv1.Task{
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"gitea_default_actions_url": structpb.NewStringValue(url),
},
},
}
}

83
internal/app/run/setup.go Normal file
View File

@@ -0,0 +1,83 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"fmt"
"os"
"runtime"
"strconv"
"strings"
"gitea.com/gitea/runner/internal/pkg/report"
"gitea.com/gitea/runner/internal/pkg/ver"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
)
// osReleasePath describes the host distribution on Linux; absent elsewhere, where the platform
// falls back to the Go runtime alone. A var so tests can point it at a fixture.
var osReleasePath = "/etc/os-release"
// reportSetup opens the job log the way actions/runner opens its "Set up job" step. The action
// downloads and the closing job name are written later, as the job starts.
func (r *Runner) reportSetup(reporter *report.Reporter, task *runnerv1.Task) {
for _, line := range r.setupLines(task) {
reporter.Logf("%s", line)
}
}
// setupLines names the runner, then reports what it was asked to run and the host it runs on, each
// in its own group.
func (r *Runner) setupLines(task *runnerv1.Task) []string {
fields := task.Context.Fields
lines := []string{
fmt.Sprintf("%s(version:%s)", r.name, ver.Version()),
"::group::Runner Information",
}
if names := r.labels.Names(); len(names) > 0 {
lines = append(lines, "Runner labels: "+strings.Join(names, ", "))
}
lines = append(lines,
// The task id correlates the job log with the runner's log and the server's task list.
fmt.Sprintf("Task: %d", task.Id),
"Job: "+fields["job"].GetStringValue(),
"Repository: "+fields["repository"].GetStringValue(),
"Triggered by event: "+fields["event_name"].GetStringValue(),
"::endgroup::",
"::group::Operating System",
)
lines = append(lines, osInfo()...)
return append(lines, "::endgroup::")
}
// osInfo describes the host the runner executes on.
func osInfo() []string {
lines := make([]string, 0, 2)
if name := prettyOSName(); name != "" {
lines = append(lines, name)
}
return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH))
}
// prettyOSName reads PRETTY_NAME (e.g. "Ubuntu 24.04.4 LTS") from os-release, or "" when absent.
func prettyOSName() string {
data, err := os.ReadFile(osReleasePath)
if err != nil {
return ""
}
for line := range strings.SplitSeq(string(data), "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || key != "PRETTY_NAME" {
continue
}
// Values are shell-quoted, but the quotes are optional.
if unquoted, err := strconv.Unquote(value); err == nil {
return unquoted
}
return value
}
return ""
}

View File

@@ -0,0 +1,103 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"gitea.com/gitea/runner/internal/pkg/labels"
"gitea.com/gitea/runner/internal/pkg/ver"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
func TestSetupLines(t *testing.T) {
original := osReleasePath
path := filepath.Join(t.TempDir(), "os-release")
require.NoError(t, os.WriteFile(path, []byte("PRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n"), 0o600))
osReleasePath = path
defer func() { osReleasePath = original }()
r := &Runner{
name: "gitea-com-gitea-0003",
labels: labels.Labels{
{Name: "ubuntu-latest", Schema: labels.SchemeDocker, Arg: "//node:20"},
{Name: "ubuntu-22.04", Schema: labels.SchemeDocker, Arg: "//node:20"},
},
}
taskCtx, err := structpb.NewStruct(map[string]any{
"job": "lint",
"repository": "gitea/runner",
"event_name": "pull_request",
})
require.NoError(t, err)
assert.Equal(t, []string{
"gitea-com-gitea-0003(version:" + ver.Version() + ")",
"::group::Runner Information",
"Runner labels: ubuntu-latest, ubuntu-22.04",
"Task: 268506",
"Job: lint",
"Repository: gitea/runner",
"Triggered by event: pull_request",
"::endgroup::",
"::group::Operating System",
"Ubuntu 24.04.4 LTS",
fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
"::endgroup::",
}, r.setupLines(&runnerv1.Task{Id: 268506, Context: taskCtx}))
}
func TestPrettyOSName(t *testing.T) {
tests := map[string]struct {
osRelease string
want string
}{
"quoted value": {
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n",
want: "Ubuntu 24.04.4 LTS",
},
"unquoted value": {
osRelease: "PRETTY_NAME=Alpine Linux v3.21\n",
want: "Alpine Linux v3.21",
},
"no pretty name": {
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n",
want: "",
},
// A key that merely ends in PRETTY_NAME must not be mistaken for it.
"similar key": {
osRelease: "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n",
want: "",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "os-release")
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
original := osReleasePath
osReleasePath = path
defer func() { osReleasePath = original }()
assert.Equal(t, tt.want, prettyOSName())
})
}
t.Run("missing file", func(t *testing.T) {
original := osReleasePath
osReleasePath = filepath.Join(t.TempDir(), "absent")
defer func() { osReleasePath = original }()
assert.Empty(t, prettyOSName())
})
}

View File

@@ -10,6 +10,8 @@ import (
"strings"
"time"
"gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect"
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
@@ -36,6 +38,7 @@ func New(endpoint string, insecure bool, uuid, token string, opts ...connect.Cli
opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
req.Header().Set("User-Agent", "gitea-runner/"+ver.Version())
if uuid != "" {
req.Header().Set(UUIDHeader, uuid)
}

View File

@@ -5,8 +5,12 @@ package client
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"connectrpc.com/connect"
pingv1 "gitea.dev/actions-proto-go/ping/v1"
"github.com/stretchr/testify/require"
)
@@ -25,3 +29,67 @@ func TestGetHTTPClientUsesProxyFromEnvironment(t *testing.T) {
require.NotNil(t, proxyURL)
require.Equal(t, "http://proxy.example.com:8080", proxyURL.String())
}
func TestGetHTTPClientInsecureTLS(t *testing.T) {
// insecure only takes effect for https endpoints
httpsInsecure := getHTTPClient("https://gitea.example.com", true)
transport, ok := httpsInsecure.Transport.(*http.Transport)
require.True(t, ok)
require.NotNil(t, transport.TLSClientConfig)
require.True(t, transport.TLSClientConfig.InsecureSkipVerify)
for _, tc := range []struct {
name string
endpoint string
insecure bool
}{
{"https secure", "https://gitea.example.com", false},
{"http insecure ignored", "http://gitea.example.com", true},
} {
t.Run(tc.name, func(t *testing.T) {
c := getHTTPClient(tc.endpoint, tc.insecure)
tr, ok := c.Transport.(*http.Transport)
require.True(t, ok)
require.Nil(t, tr.TLSClientConfig)
})
}
}
func TestNewSetsBaseURLAndHeaders(t *testing.T) {
var gotPath string
gotHeaders := make(http.Header)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotHeaders = r.Header.Clone()
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
// trailing slash must be trimmed before "/api/actions" is appended
c := New(server.URL+"/", false, "the-uuid", "the-token")
// Address returns the endpoint as supplied (untrimmed)
require.Equal(t, server.URL+"/", c.Address())
require.False(t, c.Insecure())
// the call is expected to fail (server returns 500), we only assert what was sent
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
require.True(t, strings.HasPrefix(gotPath, "/api/actions/"), "unexpected path %q", gotPath)
require.Equal(t, "the-uuid", gotHeaders.Get(UUIDHeader))
require.Equal(t, "the-token", gotHeaders.Get(TokenHeader))
}
func TestNewOmitsEmptyHeaders(t *testing.T) {
gotHeaders := make(http.Header)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeaders = r.Header.Clone()
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := New(server.URL, false, "", "")
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
require.Empty(t, gotHeaders.Get(UUIDHeader))
require.Empty(t, gotHeaders.Get(TokenHeader))
}

View File

@@ -72,6 +72,9 @@ runner:
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
# Set to false to clone the full history.
action_shallow_clone: true
# When true (the default), inject the ACT=true environment variable into jobs.
# Set to false so workflows gated on `if: ${{ !env.ACT }}` behave like they do on GitHub.
set_act_env: true
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
@@ -110,6 +113,11 @@ cache:
dir: ""
# Outbound IP or hostname that job containers use to reach this runner's cache server.
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
# If the runner itself runs in Docker, automatic detection can choose an
# address on the runner container's network that job containers cannot reach
# when the runner creates a separate per-job network. In that case, set this
# to a hostname/IP reachable from job containers, and set port to a fixed
# published port or put the job containers on a shared Docker network.
# Ignored when external_server is set.
host: ""
# Port for the built-in cache server. 0 picks a random free port.
@@ -124,6 +132,11 @@ cache:
# Required when external_server is set. Must be identical on every runner and the cache-server.
# Generate with: openssl rand -hex 32
external_secret: ""
# Path to a file containing the shared secret, as an alternative to external_secret.
# Use this to keep the secret out of this file.
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
# Setting both external_secret and external_secret_file is an error.
external_secret_file: ""
# When true, reuse a cached action instead of fetching from the remote on every job.
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
# until its cache entry expires or is manually removed.
@@ -133,6 +146,8 @@ container:
# Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network.
# If it's empty, runner will create a network automatically.
# For dockerized runners using the built-in cache server, a custom shared
# network can be required so job containers can reach cache.host/cache.port.
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
network: ""
# network_create_options only apply when `network` is left empty and the runner
@@ -168,8 +183,9 @@ container:
# If it's "-", runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
docker_host: ""
# Pull docker image(s) even if already present
force_pull: true
# Pull docker image(s) even if already present.
# Defaults to false when the key is omitted.
force_pull: false
# Rebuild docker image(s) even if already present
force_rebuild: false
# Always require a reachable docker daemon, even if not required by runner
@@ -188,11 +204,29 @@ host:
# If it's empty, $HOME/.cache/act/ will be used.
workdir_parent:
# Optional local task-admission checks. Disabled by default. When enabled, low
# disk space or a failing script pauses new task fetching; existing jobs continue.
# No health checks run while any job is active; the last result is reused until idle.
health_check:
enabled: false
# Minimum free space required on the filesystem holding runner workspaces.
# Defaults to 1024 MiB when omitted or set to zero.
min_free_disk_space_mb: 1024
# Optional additional executable. A non-zero exit, timeout, or startup failure
# marks the runner unavailable.
script: ''
# How long a script result is cached and its maximum execution time.
interval: 30s
timeout: 10s
metrics:
# Enable the Prometheus metrics endpoint.
# When enabled, metrics are served at http://<addr>/metrics and a liveness check at /healthz.
# When enabled, metrics are served at /metrics, liveness at /healthz, and
# task-admission readiness at /readyz.
enabled: false
# The address for the metrics HTTP server to listen on.
# Defaults to localhost only. Set to ":9101" to allow external access,
# but ensure the port is firewall-protected as there is no authentication.
addr: "127.0.0.1:9101"
# Consecutive polling failures may last this long before /readyz returns 503.
readiness_grace: 30s

View File

@@ -9,6 +9,7 @@ import (
"maps"
"os"
"path/filepath"
"strings"
"time"
"github.com/joho/godotenv"
@@ -49,6 +50,7 @@ type Runner struct {
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
ActionShallowClone *bool `yaml:"action_shallow_clone"` // ActionShallowClone fetches only the requested ref of an action repository at depth 1 instead of cloning every branch's full history. It is a pointer to distinguish between false and not set; if not set, it defaults to true.
SetActEnv *bool `yaml:"set_act_env"` // SetActEnv controls whether the ACT=true environment variable is injected into jobs. It is a pointer to distinguish between false and not set; if not set, it defaults to true. Set it to false so workflows gated on `if: ${{ !env.ACT }}` behave like on GitHub.
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
@@ -56,13 +58,14 @@ type Runner struct {
// Cache represents the configuration for caching.
type Cache struct {
Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true.
Dir string `yaml:"dir"` // Dir specifies the directory path for caching.
Host string `yaml:"host"` // Host specifies the caching host.
Port uint16 `yaml:"port"` // Port specifies the caching port.
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior.
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true.
Dir string `yaml:"dir"` // Dir specifies the directory path for caching.
Host string `yaml:"host"` // Host specifies the caching host.
Port uint16 `yaml:"port"` // Port specifies the caching port.
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Required whenever ExternalServer is set; ExternalSecretFile is the alternative way to provide it.
ExternalSecretFile string `yaml:"external_secret_file"` // ExternalSecretFile is the path to a file holding the ExternalSecret value, so the secret can be mounted instead of stored in the config file. LoadDefault reads it into ExternalSecret; setting both is an error.
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
}
// Container represents the configuration for the container.
@@ -94,18 +97,30 @@ type Host struct {
// Metrics represents the configuration for the Prometheus metrics endpoint.
type Metrics struct {
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
Addr string `yaml:"addr"` // Addr specifies the listen address for the metrics HTTP server (e.g., ":9101").
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
Addr string `yaml:"addr"` // Addr specifies the listen address for the metrics HTTP server (e.g., ":9101").
ReadinessGrace time.Duration `yaml:"readiness_grace"` // ReadinessGrace permits transient polling errors before /readyz becomes unhealthy.
}
// HealthCheck represents local checks that control whether the runner accepts
// new tasks. The entire feature is opt-in through Enabled.
type HealthCheck struct {
Enabled bool `yaml:"enabled"` // Enabled activates local task-admission health checks.
MinFreeDiskSpaceMB int64 `yaml:"min_free_disk_space_mb"` // MinFreeDiskSpaceMB is the minimum free space required on the work volume.
Script string `yaml:"script"` // Script is an optional executable used as an additional health check.
Interval time.Duration `yaml:"interval"` // Interval controls how long a script result is cached.
Timeout time.Duration `yaml:"timeout"` // Timeout caps one health-check script invocation.
}
// Config represents the overall configuration.
type Config struct {
Log Log `yaml:"log"` // Log represents the configuration for logging.
Runner Runner `yaml:"runner"` // Runner represents the configuration for the runner.
Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
Container Container `yaml:"container"` // Container represents the configuration for the container.
Host Host `yaml:"host"` // Host represents the configuration for the host.
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
Log Log `yaml:"log"` // Log represents the configuration for logging.
Runner Runner `yaml:"runner"` // Runner represents the configuration for the runner.
Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
Container Container `yaml:"container"` // Container represents the configuration for the container.
Host Host `yaml:"host"` // Host represents the configuration for the host.
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
HealthCheck HealthCheck `yaml:"health_check"` // HealthCheck controls opt-in local task-admission checks.
}
// LoadDefault returns the default configuration.
@@ -156,24 +171,38 @@ func LoadDefault(file string) (*Config, error) {
b := true
cfg.Runner.ActionShallowClone = &b
}
if cfg.Runner.SetActEnv == nil {
b := true
cfg.Runner.SetActEnv = &b
}
if cfg.Cache.Enabled == nil {
b := true
cfg.Cache.Enabled = &b
}
// Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled.
if err := resolveCacheExternalSecret(cfg); err != nil {
return nil, err
}
if *cfg.Cache.Enabled {
if cfg.Cache.Dir == "" {
home, _ := os.UserHomeDir()
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("cache.dir is unset and the user home directory could not be determined: %w", err)
}
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
}
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
return nil, errors.New("cache.external_server is set but cache.external_secret is empty; configure the same external_secret on this runner and the gitea-runner cache-server")
return nil, errors.New("cache.external_server is set but no shared secret is configured; set cache.external_secret (or cache.external_secret_file) to the same value used by the gitea-runner cache-server")
}
}
if cfg.Container.WorkdirParent == "" {
cfg.Container.WorkdirParent = "workspace"
}
if cfg.Host.WorkdirParent == "" {
home, _ := os.UserHomeDir()
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("host.workdir_parent is unset and the user home directory could not be determined: %w", err)
}
cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
}
if cfg.Runner.FetchTimeout <= 0 {
@@ -209,9 +238,21 @@ func LoadDefault(file string) (*Config, error) {
if cfg.Runner.PostTaskScript != "" && cfg.Runner.PostTaskScriptTimeout <= 0 {
cfg.Runner.PostTaskScriptTimeout = DefaultPostTaskScriptTimeout
}
if cfg.HealthCheck.MinFreeDiskSpaceMB <= 0 {
cfg.HealthCheck.MinFreeDiskSpaceMB = 1024
}
if cfg.HealthCheck.Interval <= 0 {
cfg.HealthCheck.Interval = 30 * time.Second
}
if cfg.HealthCheck.Timeout <= 0 {
cfg.HealthCheck.Timeout = 10 * time.Second
}
if cfg.Metrics.Addr == "" {
cfg.Metrics.Addr = "127.0.0.1:9101"
}
if cfg.Metrics.ReadinessGrace <= 0 {
cfg.Metrics.ReadinessGrace = 30 * time.Second
}
// Validate and fix invalid config combinations to prevent confusing behavior.
if cfg.Runner.FetchIntervalMax < cfg.Runner.FetchInterval {
@@ -266,3 +307,24 @@ func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
return defined, nil
}
// resolveCacheExternalSecret loads cache.external_secret from the file named by cache.external_secret_file,
// so deployments can mount the secret instead of committing it to the config file.
func resolveCacheExternalSecret(cfg *Config) error {
if cfg.Cache.ExternalSecretFile == "" {
return nil
}
if cfg.Cache.ExternalSecret != "" {
return errors.New("cache.external_secret and cache.external_secret_file are both set; configure only one of them")
}
content, err := os.ReadFile(cfg.Cache.ExternalSecretFile)
if err != nil {
return fmt.Errorf("read cache.external_secret_file %q: %w", cfg.Cache.ExternalSecretFile, err)
}
secret := strings.TrimSpace(string(content))
if secret == "" {
return fmt.Errorf("cache.external_secret_file %q contains no secret", cfg.Cache.ExternalSecretFile)
}
cfg.Cache.ExternalSecret = secret
return nil
}

View File

@@ -48,6 +48,41 @@ func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) {
assert.Equal(t, 10*time.Minute, cfg.Runner.IdleCleanupInterval)
}
func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) {
cfg, err := LoadDefault("")
require.NoError(t, err)
assert.False(t, cfg.HealthCheck.Enabled)
assert.Equal(t, int64(1024), cfg.HealthCheck.MinFreeDiskSpaceMB)
assert.Empty(t, cfg.HealthCheck.Script)
assert.Equal(t, 30*time.Second, cfg.HealthCheck.Interval)
assert.Equal(t, 10*time.Second, cfg.HealthCheck.Timeout)
assert.False(t, cfg.Metrics.Enabled)
}
func TestLoadDefault_DiskAndReadinessSettings(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
health_check:
enabled: true
min_free_disk_space_mb: 4096
script: /usr/local/bin/runner-health
interval: 15s
timeout: 3s
metrics:
readiness_grace: 45s
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.True(t, cfg.HealthCheck.Enabled)
assert.Equal(t, int64(4096), cfg.HealthCheck.MinFreeDiskSpaceMB)
assert.Equal(t, "/usr/local/bin/runner-health", cfg.HealthCheck.Script)
assert.Equal(t, 15*time.Second, cfg.HealthCheck.Interval)
assert.Equal(t, 3*time.Second, cfg.HealthCheck.Timeout)
assert.Equal(t, 45*time.Second, cfg.Metrics.ReadinessGrace)
}
func TestLoadDefault_UsesConfiguredWorkdirCleanupAge(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
@@ -192,3 +227,91 @@ func TestContainerNetworkCreateOptions(t *testing.T) {
assert.Nil(t, opts.EnableIPv6)
})
}
func TestLoadDefault_ReadsExternalSecretFromFile(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte(" s3cr3t\n"), 0o600))
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret_file: "`+secretPath+`"
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
}
func TestLoadDefault_ReadsExternalSecretFromFileWhenCacheDisabled(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
// the file has to be resolved even when cache is disabled
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: false
external_secret_file: "`+secretPath+`"
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
}
func TestLoadDefault_RejectsBothExternalSecretAndFile(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret: "inline"
external_secret_file: "`+secretPath+`"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "both set")
}
func TestLoadDefault_RejectsMissingExternalSecretFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret_file: "`+filepath.Join(dir, "absent.secret")+`"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "read cache.external_secret_file")
}
func TestLoadDefault_RejectsEmptyExternalSecretFile(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte("\n \n"), 0o600))
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret_file: "`+secretPath+`"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "contains no secret")
}

View File

@@ -0,0 +1,51 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestSaveAndLoadRegistration(t *testing.T) {
file := filepath.Join(t.TempDir(), ".runner")
reg := &Registration{
ID: 42,
UUID: "the-uuid",
Name: "runner",
Token: "the-token",
Address: "http://localhost:3000",
Labels: []string{"ubuntu:host", "ubuntu:docker://node:18"},
Ephemeral: true,
}
require.NoError(t, SaveRegistration(file, reg))
// SaveRegistration stamps the warning onto the in-memory struct
require.Equal(t, registrationWarning, reg.Warning)
loaded, err := LoadRegistration(file)
require.NoError(t, err)
// the warning is intentionally cleared on load
require.Empty(t, loaded.Warning)
loaded.Warning = reg.Warning
require.Equal(t, reg, loaded)
}
func TestLoadRegistrationMissingFile(t *testing.T) {
_, err := LoadRegistration(filepath.Join(t.TempDir(), "does-not-exist"))
require.Error(t, err)
}
func TestLoadRegistrationInvalidJSON(t *testing.T) {
file := filepath.Join(t.TempDir(), ".runner")
require.NoError(t, os.WriteFile(file, []byte("not json"), 0o600))
_, err := LoadRegistration(file)
require.Error(t, err)
}

View File

@@ -0,0 +1,20 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package envcheck
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestCheckIfDockerRunningReturnsPingError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := CheckIfDockerRunning(ctx, "unix:///definitely/missing/docker.sock")
require.Error(t, err)
require.Contains(t, err.Error(), "cannot ping the docker daemon")
}

View File

@@ -4,7 +4,7 @@
package labels
import (
"fmt"
"errors"
"strings"
)
@@ -17,13 +17,20 @@ type Label struct {
Name string
Schema string
Arg string
// Opaque marks a label whose name contains a colon but no supported schema,
// like "pool:e57e18d4-...". It is kept verbatim and behaves like a host label.
Opaque bool
}
func Parse(str string) (*Label, error) {
if str == "" {
return nil, errors.New("empty label")
}
splits := strings.SplitN(str, ":", 3)
label := &Label{
Name: splits[0],
Schema: "host",
Schema: SchemeHost,
Arg: "",
}
if len(splits) >= 2 {
@@ -33,7 +40,12 @@ func Parse(str string) (*Label, error) {
label.Arg = splits[2]
}
if label.Schema != SchemeHost && label.Schema != SchemeDocker {
return nil, fmt.Errorf("unsupported schema: %s", label.Schema)
// Not a schema we know: the colon belongs to the label name itself.
return &Label{
Name: str,
Schema: SchemeHost,
Opaque: true,
}, nil
}
return label, nil
}
@@ -59,7 +71,7 @@ func (l Labels) PickPlatform(runsOn []string) string {
case SchemeHost:
platforms[label.Name] = "-self-hosted"
default:
// It should not happen, because Parse has checked it.
// unreachable: Parse only produces host or docker schemas
continue
}
}
@@ -94,7 +106,7 @@ func (l Labels) ToStrings() []string {
ls := make([]string, 0, len(l))
for _, label := range l {
lbl := label.Name
if label.Schema != "" {
if !label.Opaque && label.Schema != "" {
lbl += ":" + label.Schema
if label.Arg != "" {
lbl += ":" + label.Arg

View File

@@ -44,7 +44,27 @@ func TestParse(t *testing.T) {
wantErr: false,
},
{
args: "ubuntu:vm:ubuntu-18.04",
args: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd",
want: &Label{
Name: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd",
Schema: "host",
Arg: "",
Opaque: true,
},
wantErr: false,
},
{
args: "ubuntu:vm:ubuntu-18.04",
want: &Label{
Name: "ubuntu:vm:ubuntu-18.04",
Schema: "host",
Arg: "",
Opaque: true,
},
wantErr: false,
},
{
args: "",
want: nil,
wantErr: true,
},
@@ -61,3 +81,91 @@ func TestParse(t *testing.T) {
})
}
}
// mustParse parses the given label strings, failing the test on any error.
func mustParse(t *testing.T, strs ...string) Labels {
t.Helper()
ls := make(Labels, 0, len(strs))
for _, s := range strs {
l, err := Parse(s)
require.NoError(t, err)
ls = append(ls, l)
}
return ls
}
func TestRequireDocker(t *testing.T) {
tests := []struct {
name string
strs []string
want bool
}{
{"empty", nil, false},
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
{"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, mustParse(t, tt.strs...).RequireDocker())
})
}
}
func TestPickPlatform(t *testing.T) {
ls := mustParse(t,
"ubuntu:docker://node:18",
"self-hosted:host",
)
tests := []struct {
name string
runsOn []string
want string
}{
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
{"host maps to self-hosted marker", []string{"self-hosted"}, "-self-hosted"},
{"first match wins", []string{"self-hosted", "ubuntu"}, "-self-hosted"},
{"unknown falls back to default", []string{"windows"}, "docker.gitea.com/runner-images:ubuntu-latest"},
{"no runsOn falls back to default", nil, "docker.gitea.com/runner-images:ubuntu-latest"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, ls.PickPlatform(tt.runsOn))
})
}
}
func TestNames(t *testing.T) {
ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host", "pool:e57e18d4")
require.Equal(t, []string{"ubuntu", "self-hosted", "pool:e57e18d4"}, ls.Names())
require.Empty(t, Labels{}.Names())
}
func TestToStrings(t *testing.T) {
ls := mustParse(t,
"ubuntu:docker://node:18",
"self-hosted:host",
"bare",
"pool:e57e18d4",
)
require.Equal(t, []string{
"ubuntu:docker://node:18",
"self-hosted:host",
"bare:host",
"pool:e57e18d4",
}, ls.ToStrings())
}
// a colon-containing name must survive a write to and read back from the .runner file
func TestOpaqueLabelRoundTrip(t *testing.T) {
const raw = "pool:e57e18d4-10d4-406f-93bf-60f127221bdd"
ls := mustParse(t, raw)
require.Equal(t, []string{raw}, ls.ToStrings())
again := mustParse(t, ls.ToStrings()...)
require.Equal(t, ls, again)
require.Equal(t, []string{raw}, again.Names())
require.False(t, again.RequireDocker())
require.Equal(t, "-self-hosted", again.PickPlatform([]string{raw}))
}

View File

@@ -0,0 +1,95 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package metrics
import (
"context"
"strings"
"sync"
"testing"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/require"
)
func TestResultToStatusLabel(t *testing.T) {
tests := []struct {
name string
result runnerv1.Result
want string
}{
{"success", runnerv1.Result_RESULT_SUCCESS, LabelStatusSuccess},
{"failure", runnerv1.Result_RESULT_FAILURE, LabelStatusFailure},
{"cancelled", runnerv1.Result_RESULT_CANCELLED, LabelStatusCancelled},
{"skipped", runnerv1.Result_RESULT_SKIPPED, LabelStatusSkipped},
{"unspecified", runnerv1.Result_RESULT_UNSPECIFIED, LabelStatusUnknown},
{"out of range", runnerv1.Result(999), LabelStatusUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, ResultToStatusLabel(tt.result))
})
}
}
func TestInitAndDynamicMetricRegistration(t *testing.T) {
oldRegistry := Registry
t.Cleanup(func() {
Registry = oldRegistry
})
Registry = prometheus.NewRegistry()
initOnce = sync.Once{}
Init()
Init()
RunnerInfo.WithLabelValues("test", "runner").Set(1)
RegisterUptimeFunc(time.Now().Add(-time.Second))
RegisterRunningJobsFunc(func() int64 { return 2 }, 4)
metrics, err := Registry.Gather()
require.NoError(t, err)
require.True(t, hasMetric(metrics, "gitea_runner_info"))
require.True(t, hasMetric(metrics, "gitea_runner_uptime_seconds"))
require.True(t, hasMetric(metrics, "gitea_runner_job_running"))
require.True(t, hasMetric(metrics, "gitea_runner_job_capacity_utilization_ratio"))
}
func TestRegisterRunningJobsFuncZeroCapacity(t *testing.T) {
oldRegistry := Registry
t.Cleanup(func() { Registry = oldRegistry })
Registry = prometheus.NewRegistry()
RegisterRunningJobsFunc(func() int64 { return 3 }, 0)
metrics, err := Registry.Gather()
require.NoError(t, err)
for _, mf := range metrics {
if mf.GetName() == "gitea_runner_job_capacity_utilization_ratio" {
require.Len(t, mf.GetMetric(), 1)
require.InDelta(t, 0, mf.GetMetric()[0].GetGauge().GetValue(), 0)
return
}
}
t.Fatal("capacity utilization metric not gathered")
}
func TestStartServerCanBeCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
StartServer(ctx, "127.0.0.1:0", nil)
cancel()
}
func hasMetric(metrics []*dto.MetricFamily, name string) bool {
for _, mf := range metrics {
if strings.EqualFold(mf.GetName(), name) {
return true
}
}
return false
}

View File

@@ -13,19 +13,12 @@ import (
)
// StartServer starts an HTTP server that serves Prometheus metrics on /metrics
// and a liveness check on /healthz. The server shuts down when ctx is cancelled.
// and health checks. The server shuts down when ctx is cancelled.
// Call Init() before StartServer to register metrics with the Registry.
func StartServer(ctx context.Context, addr string) {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
func StartServer(ctx context.Context, addr string, readiness func() (bool, string)) {
srv := &http.Server{
Addr: addr,
Handler: mux,
Handler: NewHTTPHandler(readiness),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
@@ -48,3 +41,25 @@ func StartServer(ctx context.Context, addr string) {
}
}()
}
// NewHTTPHandler returns the metrics and health endpoints used by StartServer.
func NewHTTPHandler(readiness func() (bool, string)) http.Handler {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
ready, reason := true, "ok"
if readiness != nil {
ready, reason = readiness()
}
if !ready {
w.WriteHeader(http.StatusServiceUnavailable)
}
_, _ = w.Write([]byte(reason))
})
return mux
}

View File

@@ -0,0 +1,43 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package metrics
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestReadyEndpoint(t *testing.T) {
tests := []struct {
name string
ready bool
reason string
status int
}{
{"ready", true, "ok", http.StatusOK},
{"not ready", false, "low disk space", http.StatusServiceUnavailable},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := NewHTTPHandler(func() (bool, string) { return test.ready, test.reason })
request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, test.status, response.Code)
assert.Equal(t, test.reason, response.Body.String())
})
}
}
func TestHealthEndpointStaysLiveWhenNotReady(t *testing.T) {
handler := NewHTTPHandler(func() (bool, string) { return false, "low disk space" })
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
assert.Equal(t, "ok", response.Body.String())
}

View File

@@ -0,0 +1,28 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package process
import "os"
// Group is a no-op outside Windows: process groups already reclaim a step's
// tree, and an orphan does not block workspace deletion the way an open Windows
// handle does.
type Group struct{}
// NewGroup returns a Group that owns nothing.
func NewGroup() (*Group, error) {
return &Group{}, nil
}
// Assign does nothing.
func (g *Group) Assign(_ *os.Process) error {
return nil
}
// Close does nothing.
func (g *Group) Close() error {
return nil
}

View File

@@ -0,0 +1,86 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"os"
"sync"
"unsafe"
"golang.org/x/sys/windows"
)
// Group is a job-scoped Windows Job Object holding every process the job's steps
// start; closing it terminates whatever is still assigned, reaching orphans the
// per-step Killer misses (a completed step's leftover has no parent to walk from).
type Group struct {
mu sync.Mutex
job windows.Handle
}
// NewGroup creates the job object. Closing it is what terminates the leftovers,
// so the caller must Close it when the job ends.
func NewGroup() (*Group, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return nil, err
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
); err != nil {
_ = windows.CloseHandle(job)
return nil, err
}
return &Group{job: job}, nil
}
// Assign adds a started process to the job; anything it spawns afterwards joins
// too. Call before NewKiller so the step's job nests inside this one. Nil-safe.
func (g *Group) Assign(p *os.Process) error {
if g == nil || p == nil {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
if g.job == 0 {
return nil
}
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
if err != nil {
return err
}
defer func() { _ = windows.CloseHandle(h) }()
return windows.AssignProcessToJobObject(g.job, h)
}
// Close drops the job handle, terminating every process still assigned to it.
// Nil-safe and idempotent.
func (g *Group) Close() error {
if g == nil {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
if g.job == 0 {
return nil
}
h := g.job
g.job = 0
return windows.CloseHandle(h)
}

View File

@@ -0,0 +1,113 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// startOrphanMaker starts a process that spawns a detached, long-lived child and
// exits, leaving the child parentless. Returns the cmd and the child's PID file.
func startOrphanMaker(t *testing.T) (*exec.Cmd, string) {
t.Helper()
pidFile := filepath.Join(t.TempDir(), "child.pid")
script := fmt.Sprintf(
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
`Set-Content -LiteralPath %q -Value $c.Id`, pidFile)
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script)
require.NoError(t, cmd.Start())
t.Cleanup(func() { _ = cmd.Process.Kill() })
return cmd, pidFile
}
// awaitChildPID waits until the spawned child has reported its PID and is running.
func awaitChildPID(t *testing.T, pidFile string) int {
t.Helper()
var childPID int
require.Eventually(t, func() bool {
b, err := os.ReadFile(pidFile)
if err != nil {
return false
}
s := strings.TrimSpace(string(b))
if s == "" {
return false
}
childPID, _ = strconv.Atoi(s)
return childPID > 0 && processAlive(childPID)
}, 20*time.Second, 200*time.Millisecond, "child process should start")
return childPID
}
// TestGroupClosePropagatesToOrphan covers what the per-step Killer cannot: a
// completed step's orphan, reachable only by closing the job object.
func TestGroupClosePropagatesToOrphan(t *testing.T) {
group, err := NewGroup()
require.NoError(t, err)
t.Cleanup(func() { _ = group.Close() })
cmd, pidFile := startOrphanMaker(t)
require.NoError(t, group.Assign(cmd.Process))
childPID := awaitChildPID(t, pidFile)
require.NoError(t, cmd.Wait()) // the step process exits cleanly, like a passing step
require.Eventually(t, func() bool {
return !processAlive(cmd.Process.Pid)
}, 20*time.Second, 200*time.Millisecond, "the step process should have exited on its own")
require.True(t, processAlive(childPID), "orphan should outlive its parent")
require.NoError(t, group.Close())
require.Eventually(t, func() bool {
return !processAlive(childPID)
}, 20*time.Second, 200*time.Millisecond, "closing the job must terminate the orphan")
}
// TestGroupNestsWithKiller: assigned to the group first and the step's Killer
// second, cancelling the step still kills exactly that step's tree.
func TestGroupNestsWithKiller(t *testing.T) {
group, err := NewGroup()
require.NoError(t, err)
t.Cleanup(func() { _ = group.Close() })
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Sleep -Seconds 600")
require.NoError(t, cmd.Start())
t.Cleanup(func() { _ = cmd.Process.Kill() })
require.NoError(t, group.Assign(cmd.Process))
// Nesting must be accepted, else cancellation falls back to a single-process kill.
killer, err := NewKiller(cmd.Process)
require.NoError(t, err, "the step's job object must nest inside the group's")
defer killer.Close()
require.NoError(t, killer.Kill())
require.Eventually(t, func() bool {
return !processAlive(cmd.Process.Pid)
}, 20*time.Second, 200*time.Millisecond, "cancelling the step should still kill its tree")
}
// TestGroupNilSafe covers the fallback path: when the job object cannot be
// created the caller holds a nil *Group and must still be able to use it.
func TestGroupNilSafe(t *testing.T) {
var group *Group
require.NoError(t, group.Assign(nil))
require.NoError(t, group.Close())
require.NoError(t, group.Close())
}

View File

@@ -0,0 +1,29 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"path/filepath"
"strings"
)
// pathWithin is the OS-agnostic core of dirContainsPath: dir and target must
// already be cleaned, and fold folds case.
func pathWithin(dir, target string, fold bool) bool {
if dir == "" || target == "" || dir == "." || target == "." {
return false
}
if fold {
dir = strings.ToLower(dir)
target = strings.ToLower(target)
}
if dir == target {
return true
}
sep := string(filepath.Separator)
if !strings.HasSuffix(dir, sep) {
dir += sep
}
return strings.HasPrefix(target, dir)
}

View File

@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package process
import "context"
// KillProcessesWithCWDUnder is a no-op outside Windows: an open handle does not
// block os.RemoveAll there.
func KillProcessesWithCWDUnder(_ context.Context, _ []string) (int, error) {
return 0, nil
}

View File

@@ -0,0 +1,41 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"path/filepath"
"testing"
)
func TestPathWithin(t *testing.T) {
base := filepath.Join("base", "job1")
cases := []struct {
name string
dir string
target string
fold bool
want bool
}{
{"same dir", base, base, false, true},
{"direct child", base, filepath.Join(base, "app.exe"), false, true},
{"deep child", base, filepath.Join(base, "sub", "sub", "app.exe"), false, true},
{"parent is not within child", filepath.Join(base, "sub"), base, false, false},
{"name-prefix sibling spared", base, filepath.Join("base", "job10", "app.exe"), false, false},
{"unrelated", base, filepath.Join("other", "app.exe"), false, false},
{"empty dir", "", base, false, false},
{"empty target", base, "", false, false},
{"dot dir", ".", base, false, false},
{"case-sensitive miss", base, filepath.Join("base", "JOB1", "app.exe"), false, false},
{"case-insensitive hit", base, filepath.Join("base", "JOB1", "app.exe"), true, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := pathWithin(filepath.Clean(tc.dir), filepath.Clean(tc.target), tc.fold)
if got != tc.want {
t.Fatalf("pathWithin(%q, %q, fold=%v) = %v, want %v", tc.dir, tc.target, tc.fold, got, tc.want)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More