Compare commits

...

62 Commits

Author SHA1 Message Date
bircni
fc0e03e5a9 feat: run pre-entrypoint and post-entrypoint of docker actions (#1106)
A docker action can declare `runs.pre-entrypoint` and `runs.post-entrypoint` next to `runs.entrypoint` — the docker equivalent of a javascript action's `runs.pre`/`runs.post`. Neither key existed on `ActionRuns`, so the YAML decoder dropped them and docker actions silently skipped their setup and cleanup stages.

Only the entrypoint is stage specific. [`ContainerActionHandler`](https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/ContainerActionHandler.cs) selects `Data.Pre`/`Data.Post` per stage but evaluates `runs.args` and `runs.env` unconditionally, so `docker create` receives the action's args and env on every stage. The `entrypoint` input stays main only, because it is read inside the main branch.

The stage is carried as the existing `stepStage` value rather than a separate parameter, and the pre and post stages reuse the action path resolution that `runPreStep`/`runPostStep` already open-coded three times, so the diff also drops those copies.

Known gap: `stepActionLocal.pre()` is a no-op for every `using`, so `pre-entrypoint` does not run for `uses: ./local-action` while `post-entrypoint` does. Closing it requires reading the action model before the main stage and adding a pre case to `getIfExpression` for `pre-if`, which also changes local node, go and composite actions — better as its own change.

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1106
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-28 05:36:35 +00:00
bircni
333eb17d19 feat: report runner name, environment, workspace and debug to jobs (#1105)
Passes the `runner` context values act already knew but never reported to jobs:

1. `runner.name` / `RUNNER_NAME` — registered runner name, hostname for `exec`
2. `runner.environment` / `RUNNER_ENVIRONMENT` — `self-hosted`
3. `RUNNER_WORKSPACE` — parent of `GITHUB_WORKSPACE`
4. `runner.debug` / `RUNNER_DEBUG` — `1` when `ACTIONS_STEP_DEBUG` is set

`ImageOS` now prefers the release named in the resolved image tag, so `ubuntu-latest` mapped to `runner-images:ubuntu-24.04` reports `ubuntu24` instead of the hardcoded `ubuntu20`. The `runs-on` label stays the fallback.

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1105
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-28 05:29:34 +00:00
silverwind
c3b39e0d99 fix: escape command data the runner writes itself (#1120)
Command data is percent-escaped on the wire because Gitea's job log cannot carry a newline, and the renderer decodes it.

1. Unescaping iterated a map, so order was random per process: `%250A` decoded to a newline instead of a literal `%0A` about two runs in three. Now a precompiled `strings.NewReplacer`.
1. Escape the data of command lines the runner writes itself (`##[error]`, `::group::Run …`), so decoding returns the original text instead of mangling a `%`. Multi-line runner errors also get real line breaks instead of a literal `\n`.
1. Register secrets in escaped form too — one containing `%` reaches the log as `%25…` and was never masked.
1. Decode in `jobLogFormatter`, so `gitea-runner exec` matches the web view.

## Relation to the Gitea PR

https://github.com/go-gitea/gitea/pull/38659 makes the renderer decode command data. Point 2 is required by it; the rest stand alone. Either order works — until both land, a new runner on old Gitea shows `%25`, an old runner on new Gitea shows the mangling point 2 fixes.
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1120
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-27 15:16:49 +00:00
Renovate Bot
78a74f78f8 chore(deps): pin dependencies (#1117)
This PR contains the following updates:

| Package | Type | Update | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|---|
| [actions/checkout](https://github.com/actions/checkout) | action | pinDigest |  → `3d3c42e` |  |  |
| [actions/setup-go](https://github.com/actions/setup-go) | action | pinDigest |  → `b7ad1da` |  |  |
| [actions/setup-node](https://github.com/actions/setup-node) | action | pinDigest |  → `8207627` |  |  |
| [crazy-max/ghaction-import-gpg](https://github.com/crazy-max/ghaction-import-gpg) | action | pinDigest |  → `2dc316d` |  |  |
| [docker/build-push-action](https://github.com/docker/build-push-action) | action | pinDigest |  → `53b7df9` |  |  |
| [docker/login-action](https://github.com/docker/login-action) | action | pinDigest |  → `abd2ef4` |  |  |
| [docker/metadata-action](https://github.com/docker/metadata-action) | action | pinDigest |  → `dc80280` |  |  |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | action | pinDigest |  → `bb05f3f` |  |  |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | action | pinDigest |  → `96fe6ef` |  |  |
| [go.yaml.in/yaml/v4](https://github.com/yaml/go-yaml) | require | patch | `v4.0.0-rc.3` → `v4.0.0-rc.6` | ![age](https://developer.mend.io/api/mc/badges/age/go/go.yaml.in%2fyaml%2fv4/v4.0.0-rc.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.yaml.in%2fyaml%2fv4/v4.0.0-rc.3/v4.0.0-rc.6?slim=true) |
| [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | action | pinDigest |  → `f06c13b` |  |  |
| ubuntu | final | major | `24.04` → `26.04` | ![age](https://developer.mend.io/api/mc/badges/age/docker/ubuntu/resolute?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/ubuntu/24.04/resolute?slim=true) |

---

### Release Notes

<details>
<summary>yaml/go-yaml (go.yaml.in/yaml/v4)</summary>

### [`v4.0.0-rc.6`](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.5...v4.0.0-rc.6)

[Compare Source](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.5...v4.0.0-rc.6)

### [`v4.0.0-rc.5`](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.4...v4.0.0-rc.5)

[Compare Source](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.4...v4.0.0-rc.5)

### [`v4.0.0-rc.4`](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.3...v4.0.0-rc.4)

[Compare Source](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.3...v4.0.0-rc.4)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

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

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

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

---

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

---

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

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1117
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-27 14:38:07 +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
Christian Heim
b7f6b6d90a fix: composite nested uses clone token (#1041)
## Summary

A nested `uses:` action inside a **local composite action** fails to clone with a bare `authentication required: Unauthorized` (401) when the instance resolves host-less action references against itself (`DEFAULT_ACTIONS_URL = self`). The job fails at the composite **main→post boundary**, even though the composite's own steps and all post-steps report success.

## Root cause

`newCompositeRunContext` nils `Config.Secrets` so composite steps do not see job secrets. But the action-clone path in `prepareActionExecutor` sourced its token via `getGitCloneToken` → `Config.GetToken()` → `Config.Secrets`, which is empty inside a composite RunContext. The nested action is therefore cloned anonymously → 401 against the authenticated instance.

Two details explain the exact symptom:

- It surfaces at the composite **main→post boundary** because the swallowed nested-step error is re-emitted by `common.JobError` at the end of the composite main pipeline.
- It carries **no clone URL** because, on a warm action cache, only `r.Fetch` runs (not `PlainClone`), and the go-git fetch error is returned verbatim.

## Fix

Source the clone token from `github.Token` instead of `Config.Secrets`. It is preserved across the composite config copy (`Config.Token` / `PresetGitHubContext`) and is identical to `Config.GetToken()` at the top level, so top-level and `act exec` behaviour is unchanged. The `shouldCloneURLUseToken` host gate is kept so the token is never sent to a foreign host. This also aligns the git-clone path with the ActionCache fetch path, which already uses `github.Token`.

Reusable workflows are unaffected — their RunContext keeps `Config.Secrets`.

## Before / after

Local composite action with a nested `uses:` (+ post step), followed by a marker step. Same workflow, same runner host — only the runner fix differs.

| Job step | Before fix | After fix |
| --- | --- | --- |
| `Run actions/checkout@v6` |  success |  success |
| `Run ./.gitea/actions/probe-composite` (nested `uses:` + post) |  **failure** — bare 401 at the main→post boundary |  success |
| `Step after composite` | ⊘ **skipped** |  **ran** |
| Job result |  **failed** |  **succeeded** |

### Before — boundary log

```text
::endgroup::
##[error]authentication required: Unauthorized   ← bare 401, no clone target
Run Post ./.gitea/actions/probe-composite
Success - Post ./.gitea/actions/probe-composite  ← post steps run and succeed
Success - Post actions/checkout@v6
Job failed
```

The composite's own steps and both post-steps report `Success`; the job fails solely on the bare 401 emitted at the main→post boundary, and the next step is skipped.

### After — boundary log

```text
Run ./.gitea/actions/probe-composite
  ...composite steps...
Success - ./.gitea/actions/probe-composite
Run Marker after composite
PASSED composite boundary — no 401 (runner fix confirmed)
Success - Marker after composite
Job succeeded
```

## Reproduction

`.gitea/actions/probe-composite/action.yml`:

```yaml
name: probe-composite
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v6   # nested uses → has a post step
      with: { node-version: 22 }
    - run: echo "composite inner step OK"
      shell: bash
```

`.gitea/workflows/probe.yaml`:

```yaml
on: [push]
jobs:
  composite-boundary:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: ./.gitea/actions/probe-composite
      - run: echo "step after composite"   # skipped before the fix; runs after
```

## Verification

- **Before:** bare 401 at the boundary, reproduced with a `delay=0` tail — rules out a token-TTL / expiry effect; it is a missing credential.
- **After:** the composite step succeeds, the step after the composite runs, the job succeeds, and there is no `authentication required` in the log.
- A reusable workflow (`workflow_call`) with the same nested `uses:` + post step never hit the 401, which isolated the bug to the composite main/post path.

## Tests

Adds `TestStepActionRemoteCloneTokenSurvivesNilSecrets` (`act/runner`): asserts the clone token is forwarded when the RunContext mirrors a composite (`Secrets == nil`, token via `Config.Token`), and that the host gate still withholds the token for a foreign host. Verified to fail without the fix and pass with it.

---------

Reviewed-on: https://gitea.com/gitea/runner/pulls/1041
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: Christian Heim <christian@heimdaheim.de>
Co-committed-by: Christian Heim <christian@heimdaheim.de>
2026-06-29 06:23:54 +00:00
bircni
cdcea87a45 fix: namespace local docker action image tags per repository (#1051)
Fixes #1039 — local docker actions referenced with `uses: ./` are incorrectly served from a cached image built for a *different* repository.

## Root cause

For a local docker action, the built image tag is derived from `actionName`, which is the **workspace-relative** path of the action. For a self-referencing action that is always `"./"`, regardless of which repository it lives in. In `execAsDocker` this collapsed to the constant tag `act-dockeraction:latest` for *every* repository.

Before building, the runner checks `ContainerImageExistsLocally` and skips the rebuild if a matching tag is present. On a shared docker daemon, once repository A built its image, repository B's `uses: ./` found the same tag, skipped its rebuild, and **ran repository A's action image** — the reported "the first action is run again, because the path is cached", including the cross-repository concern raised in the issue.

## Fix

Extracted the tag computation into a small, testable `dockerActionImageTag(repository, actionName, localAction)` function. For local actions the tag is now namespaced with the repository (`path.Join(repository, actionName)`):

- different repositories no longer collide (`act-owner-repo-a-dockeraction:latest` vs `act-owner-repo-b-dockeraction:latest`);
- image caching is preserved within a repository (tag stays stable);
- remote actions are unchanged — they already carry a unique, ref-scoped `actionName` (the uses hash).

---------

Co-authored-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1051
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-committed-by: bircni <bircni@icloud.com>
2026-06-29 06:15:37 +00:00
Nicolas
3c4bcf3ebf build: cover Windows Go files (#1052)
- fix Windows-only golangci-lint findings in `lp_windows.go` and process killer handle cleanup
- add a `lint-go-windows` target that runs golangci-lint with `GOOS=windows`
- include the Windows lint pass in `make lint` so Ubuntu CI covers `_windows.go` files

Reviewed-on: https://gitea.com/gitea/runner/pulls/1052
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-06-28 21:06:51 +00:00
Nicolas
e22d3fa263 fix: run always()/cancelled() and post steps correctly on cancellation (#1043)
## Problem

When a workflow run is **cancelled**, the runner diverged from GitHub Actions:

- Main-stage `if: ${{ always() }}` / `if: ${{ cancelled() }}` steps **did not run** at all (unlike a *failed* run, where they do).
- `if: ${{ cancelled() }}` was **structurally impossible** to satisfy — it could never be true.

GitHub Actions runs `always()`/`cancelled()` steps (and post cleanup) even when a job is cancelled. This runner only honored that for action *post* steps (since #1016), leaving main-stage cleanup steps silently skipped.

## Root causes (both in `act/`)

1. **`getJobContext()`** derived the job status purely from step conclusions, so it could only ever return `"success"` or `"failure"`. Since `cancelled()` checks `Job.Status == "cancelled"`, it was impossible — and `success()` stayed *true* on a cancelled run, so the wrong `if` branch was taken everywhere.

2. **The main step pipeline** is chained with `Executor.Then()`, which short-circuits the moment `ctx.Err() != nil`. Once the server cancelled, every not-yet-started main step (including `always()` ones) was abandoned.

## Fix

- Add a per-`RunContext` `jobCancelled` flag + `markCancelled()`. `getJobContext()` now reports `"cancelled"` (taking precedence over success/failure), so `cancelled()`/`always()` are true and `success()`/`failure()` are false — matching GitHub's "only always()/cancelled() run on cancel" semantics.
- Replace the plain main-steps pipeline with `newMainStepsExecutor`. On interruption (`context.Canceled` from a server cancel, or `context.DeadlineExceeded` from the job timeout) it marks the job cancelled and runs the **remaining** steps under a fresh context (`context.WithoutCancel` + bounded timeout) so `always()`/`cancelled()` steps run for cleanup, while default-`success()` steps skip themselves. The original interrupt error is still propagated upward.
- Backstop `markCancelled()` in the post-step `Finally` so cancellations landing outside the main loop still surface the cancelled status to post steps.

Pre-steps keep normal short-circuit behavior, and reporting (`RESULT_CANCELLED`) is untouched — that remains handled by #1016.

## Reporting semantics (unchanged by this PR)

| Run state | failing post/`always()` step reported as                                                      |
| --------- | --------------------------------------------------------------------------------------------- |
| Normal    | **FAILURE**                                                                                   |
| Timeout   | **FAILURE** (deadline path preserves the job-error container)                                 |
| Cancelled | **CANCELLED** — cancellation wins; the failing step is logged but doesn't flip the conclusion |

The new `always()` path runs under `context.WithoutCancel`, so the job-error container is preserved — a failing `always()` step records its failure at step level and does not panic in `SetJobError`.

Fixes #657

Reviewed-on: https://gitea.com/gitea/runner/pulls/1043
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-06-28 20:54:00 +00:00
Zettat123
99bc50d538 feat: shallow clone action repositories (#1053)
## Summary

When a workflow references a remote action (e.g. `uses: actions/checkout@v4`) the runner clones that repository during job setup.
Previously this was always a full clone(every branch and the complete history) even though only a single ref is needed.

This PR makes the runner shallow-clone the requested ref by default (`--depth=1`, single branch), falling back to a full clone when a shallow clone fails.

Notes:
- Existing on-disk caches are reused as-is; there is no forced re-clone on upgrade.

## Changes

- A new `runner.action_shallow_clone` option (default `true`) lets operators opt back into full clones.
- `cloneAtDepth`: attempt a shallow clone; fall back to a full clone when shallow clone fails.
- Keep a shallow cache cheap on update: fetch the single requested ref at depth 1 and skip `pull`.

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1053
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
2026-06-28 20:12:21 +00:00
bircni
8f72c60afa fix: guard SetJobError against missing job-error container (#1050)
Fixes https://gitea.com/gitea/runner/issues/1047

The runner panics during shutdown when jobs are interrupted:

```
panic in executor: interface conversion: interface {} is nil, not map[string]error
...
gitea.com/gitea/runner/act/common.SetJobError(...)
    /data/gitea/runner/act/common/job_error.go:27
gitea.com/gitea/runner/act/runner.reportStepError(...)
    /data/gitea/runner/act/runner/job_executor.go:62
```

`SetJobError` did an unchecked type assertion on the job-error container stored in the context:

```go
ctx.Value(jobErrorContextKeyVal).(map[string]error)["error"] = err
```

When the container is absent — e.g. on shutdown, when a job is interrupted and `reportStepError` → `SetJobError` runs on a context where `WithJobErrorContainer` was never called — `ctx.Value` returns `nil` and asserting `nil.(map[string]error)` panics.

## Fix

Use the comma-ok form so a missing container is a no-op, matching the safe pattern already used in the sibling `JobError` function. If there's no container, there's nowhere to record the error anyway, so skipping is correct.

```go
func SetJobError(ctx context.Context, err error) {
	if container, ok := ctx.Value(jobErrorContextKeyVal).(map[string]error); ok {
		container["error"] = err
	}
}
```

Reviewed-on: https://gitea.com/gitea/runner/pulls/1050
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-committed-by: bircni <bircni@icloud.com>
2026-06-28 08:50:41 +00:00
Renovate Bot
4e7fd1c68a fix(deps): update module github.com/moby/moby/client to v0.5.0 (#1049)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/moby/moby/client](https://github.com/moby/moby) | `v0.4.1` → `v0.5.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fmoby%2fclient/v0.5.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fmoby%2fclient/v0.4.1/v0.5.0?slim=true) |

---

### Release Notes

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

### [`v0.5.0`](https://github.com/moby/moby/compare/v0.4.1...v0.5.0)

[Compare Source](https://github.com/moby/moby/compare/v0.4.1...v0.5.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/1049
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-27 13:56:34 +00:00
Renovate Bot
bd41a367fe fix(deps): update module github.com/docker/cli to v29.6.0+incompatible (#1045)
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.5.3+incompatible` → `v29.6.0+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.6.0+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.5.3+incompatible/v29.6.0+incompatible?slim=true) |

---

### Release Notes

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

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

[Compare Source](https://github.com/docker/cli/compare/v29.5.3...v29.6.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/1045
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-25 10:50:32 +00:00
Renovate Bot
c566013db4 chore(deps): update docker docker tag to v29.6.0 (#1044)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| docker | stage | minor | `29.5.3-dind-rootless` → `29.6.0-dind-rootless` |
| docker | stage | minor | `29.5.3-dind` → `29.6.0-dind` |

---

### 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 these updates 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/1044
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-25 10:42:52 +00:00
Renovate Bot
40e021309a chore(deps): update actions/checkout action to v7 (#1042)
Reviewed-on: https://gitea.com/gitea/runner/pulls/1042
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-24 14:32:12 +00:00
Renovate Bot
d3b3519dea fix(deps): update module go.etcd.io/bbolt to v1.5.0 (#1040)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [go.etcd.io/bbolt](https://github.com/etcd-io/bbolt) | `v1.4.3` → `v1.5.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/go.etcd.io%2fbbolt/v1.5.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.etcd.io%2fbbolt/v1.4.3/v1.5.0?slim=true) |

---

### Release Notes

<details>
<summary>etcd-io/bbolt (go.etcd.io/bbolt)</summary>

### [`v1.5.0`](https://github.com/etcd-io/bbolt/releases/tag/v1.5.0): v0.5.0

[Compare Source](https://github.com/etcd-io/bbolt/compare/v1.4.3...v1.5.0)

See the [CHANGELOG/v1.5.0](https://github.com/etcd-io/bbolt/blob/main/CHANGELOG/CHANGELOG-1.5.md#v1502026-06-21) for more details.

</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/1040
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-23 00:07:52 +00:00
Nicolas
6bdcb54828 feat: Enable jobs.<job_id>.timeout-minutes and jobs.<job_id>.continue-on-error (#1032)
Two `jobs.<job_id>` workflow syntax fields were parsed from YAML but silently ignored. This PR implements both:

- **`jobs.<job_id>.timeout-minutes`** — applies a context deadline around the entire job execution (container start, pre-steps, main steps, post-steps). Mirrors the existing step-level `evaluateStepTimeout`. Supports expression interpolation (e.g. `${{ env.MY_TIMEOUT }}`).

- **`jobs.<job_id>.continue-on-error`** — evaluates the expression when a job fails. If all failing matrix combinations had `continue-on-error: true`, the job does not cause the workflow run to fail (`handleFailure` skips it), and the tolerated failure reports `success` to dependent jobs through the `needs` context so jobs gated on the default `if: success()` still run (matching GitHub). The "any firm failure wins" rule is serialised under the existing per-job lock, so parallel matrix combinations are safe.

Both features follow the same patterns already used at the step level (`evaluateStepTimeout` / `isContinueOnError` in `act/runner/step.go`).

## Version compatibility

These changes are backward compatible. With mismatched versions the feature degrades silently to the previous behaviour (field ignored) — no errors on either side.

- `timeout-minutes`: runner-only, no server dependency.
- `continue-on-error`: requires both this runner PR and the matching Gitea server PR to take full effect. With only one side updated, the field continues to be ignored.

Related: [Github](https://github.com/go-gitea/gitea/pull/38100)
---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1032
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-06-21 17:05:36 +00:00
Nicolas
007717956a feat: Add optional runner.post_task_script hook after task cleanup (#1026)
- Adds `runner.post_task_script` and `runner.post_task_script_timeout` (default `5m`) to run a host executable after each task’s built-in cleanup (post-steps, container teardown, bind-workdir removal).
- Stops task heartbeats via `Reporter.StopHeartbeats()` while the script runs so Gitea won’t assign overlapping work; the final task acknowledgement still happens in `reporter.Close()`.
- Script output goes to the runner process log; non-zero exits are warned only and do not change the job result.
- Documents lifecycle, offline behavior, timeouts, and Windows limits (`.ps1` not supported yet) in `docs/post-task-script.md`.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1026
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-06-19 19:28:10 +00:00
Nicolas
df0370f8bf fix: Interpolate job container.volume (#1036)
Interpolate job container.volumes in GetBindsAndMounts(), matching service container volumes and other container fields (image, options).

  Fixes expressions like ${{ secrets.MAME }}:/path:ro being passed literally and rejected as invalid bind mounts

Reviewed-on: https://gitea.com/gitea/runner/pulls/1036
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-18 02:55:30 +00:00
Nicolas
5f0636faad feat: Support ssh:// action URLs (#1035)
Adds `ssh://` to the list of recognized URL schemes in `newRemoteAction`, so a
step can reference an action over SSH, e.g.:

```yaml
uses: ssh://git@gitea.example.com/actions/checkout@v4
```

Previously only `https://` / `http://` prefixes were parsed; an `ssh://` URL
fell through to the bare `org/repo` parser and failed.

### How auth works

SSH auth is delegated entirely to go-git's defaults — the runner configures no
SSH-specific options:

- **Which key?** go-git falls back to the host's **ssh-agent** (`$SSH_AUTH_SOCK`).
  There is no key-file fallback, so the agent must hold a usable key. The SSH
  **username** comes from the URL, so use `ssh://git@host/...` (a bare
  `ssh://host/...` authenticates as an empty user and most servers reject it).
- **Host key trust?** Established out-of-band via the host's `known_hosts`
  (`$SSH_KNOWN_HOSTS`, `~/.ssh/known_hosts`, `/etc/ssh/ssh_known_hosts`). The
  runner host must already trust the remote; there is no accept-on-first-use.
- **Host key changes?** The clone fails with a host-key-mismatch error and stays
  failed until `known_hosts` is updated on the host. Note `InsecureSkipTLS` does
  **not** apply to SSH.

### Caching

The action cache path is derived from `{org}/{repo}` only (scheme/host are not
part of the key), so an `ssh://` action shares cache storage with the same
`org/repo` fetched over HTTP. This is unchanged by this PR and works in practice
(fetches resolve by SHA), but is worth noting.

### Tests

Adds `ssh://` cases to `Test_newRemoteAction` covering the scheme prefix, the
`git@` username placement, and a malformed-URL rejection. The agent/known_hosts
behavior lives in go-git and is not unit-tested here.

Fixes #841

Reviewed-on: https://gitea.com/gitea/runner/pulls/1035
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-17 20:28:40 +00:00
Nicolas
4997f33b5f docs: Improve the documentation for cache (#1034)
Reviewed-on: https://gitea.com/gitea/runner/pulls/1034
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-committed-by: Nicolas <bircni@icloud.com>
2026-06-15 21:50:42 +00:00
StarAurryon
2963716953 feat: ipv6 options for network container creation (#1029)
Here is a final proposal for ipv6 enablement on temporary network created by gitea runner

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: Nicolas Schwartz <9308314+StarAurryon@users.noreply.github.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1029
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: StarAurryon <206206+staraurryon@noreply.gitea.com>
Co-committed-by: StarAurryon <206206+staraurryon@noreply.gitea.com>
2026-06-15 05:05:20 +00:00
Nicolas
3996d6d032 fix(cleanup): kill Unix step process group on cancel to avoid hang (#1025)
Cancelling a job on a Linux/macOS host runner can leave the spawned process
tree running and hang the runner — the same failure mode fixed for Windows in
#1011, just on the other platforms.

Steps are launched as process-group leaders (`Setpgid`, or `Setsid` for the PTY
path), but the default `exec.CommandContext` cancellation only kills the
**direct child**. When a step launches a shell that starts a child which in turn
spawns further background processes, cancelling the job leaves the descendants
running. Because those orphans inherited the step's stdout/stderr pipe, the read
end never hits EOF and `cmd.Wait()` blocks forever.

Because the step executor never returns:
- the orphaned processes keep running (the cancelled work is not actually
  stopped), and
- end-of-job cleanup is never reached, so the runner appears to go offline / stop
  picking up jobs.

## Fix

Apply the same tree-kill approach as Windows, using the Unix counterpart of a Job
Object: the **process group**.

- Add a Unix `processKiller` (`process_unix.go`) that captures the step's PGID
  (== PID, since the step is launched as a group leader) and sends `SIGKILL` to
  the whole group on cancellation. This also closes the inherited pipe handles so
  `cmd.Wait()` can return. `ESRCH` (group already gone) is not treated as an error.
- Restrict the previous no-op stub (`process_other.go`) to `plan9` and have it
  fall back to a single-process kill, preserving plan9's prior behaviour.
- Wire `cmd.Cancel` (tree kill) and `cmd.WaitDelay` (10s) **unconditionally** in
  `exec()` instead of Windows-only. `WaitDelay` also covers a step that
  backgrounds a process holding the pipe open after the main process exits.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1025
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-06-14 20:52:42 +00:00
Nicolas
205af7cd01 fix: prevent loss of step log output at end of step (#1028)
## Problem

Several runner code paths could drop the **tail** of a step's log output, so a
failing (or cancelled) step would show output that is missing its last line(s).
This was observed in practice and traced to four independent issues.

## Root causes & fixes

### 1. Trailing line without a newline was never flushed
`common.lineWriter` buffers output until it sees a `\n`. A final line **without**
a trailing newline (e.g. an error message printed right before a process exits,
a panic, `printf` without `\n`) stayed in the internal buffer and was never
emitted — the writer exposed no flush at all.

- Added `lineWriter.Flush()` (idempotent), a `Flusher` interface, and a
  `FlushWriter(io.Writer)` helper.
- Flush at every stream EOF: the exec copy goroutine, the container `attach()`
  streaming goroutine, and at step end (`useStepLogger`).

### 2. Cancellation/timeout truncated output
`waitForCommand` returned immediately on `ctx.Done()` and abandoned the
output-copy goroutine, losing output the command had already produced. It now
drains with a bounded grace period before returning. The response channel is
buffered so the goroutine can't leak if the drain times out.

### 3. `attach()` raced the final bytes
Container output was streamed in a fire-and-forget goroutine that `wait()` did
not synchronize with, so the step could proceed before the last bytes were
written. `wait()` now blocks on the streaming goroutine (bounded) so output is
fully drained and flushed first.

### 4. `::stop-commands::` silently dropped lines from the step log
Lines between `::stop-commands::<token>` and its end token were echoed without
the `raw_output` field **and** short-circuited the handler chain (`return false`),
so they never reached the step log (non-raw entries aren't appended while a step
is running). Now returns `true` so they are still captured.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1028
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-06-14 20:43:19 +00:00
Nicolas
33e6d1d8ff fix(host): bound host-environment cleanup and reclaim leaked scratch dirs (#1024)
Fixes #1023.

## Problem
In Windows host mode, a single stalled delete syscall (AV/EDR filter driver, unresponsive mount, dying disk) wedged the job forever at `Cleaning up container`. `HostEnvironment.Remove()` bounds every teardown phase (`terminateRunningProcesses`, both `removePathWithRetry` calls) except the `CleanUp` callback — an unbounded `os.RemoveAll(miscpath)` assigned in `startHostEnvironment`. The runner then held its capacity slot indefinitely, the task was reaped as a zombie, and there were no diagnostics.

## Fix
- **Bound the cleanup (availability):** `Remove()` now runs `CleanUp` under `hostCleanupTimeout` (30s) via `runWithTimeout`; on timeout it logs a warning and continues job completion. The stuck goroutine is left to finish (a delete syscall can't be interrupted). Added debug logs around the phase.
- **Reclaim the leak (disk hygiene):** a timed-out cleanup can leave a scratch dir behind, so the existing idle stale-dir sweep is extended to also remove orphaned host-mode scratch dirs (16-hex names) under `Host.WorkdirParent`, leaving the shared `tool_cache` and operator data untouched. The `bind_workdir` gate is dropped from `shouldRunIdleCleanup` so host-mode runners run the sweep.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1024
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-14 14:14:43 +00:00
146 changed files with 9430 additions and 832 deletions

View File

@@ -18,8 +18,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- 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@v6
- 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@v6
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@v6
- 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@v6
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@v6
- 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.5.3-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.5.3-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

@@ -38,12 +38,15 @@ endif
ifeq ($(OS), Windows_NT)
GOFLAGS := -v -buildmode=exe
EXECUTABLE ?= $(EXECUTABLE).exe
GO_ENV_WINDOWS := set GOOS=windows&&
else ifeq ($(OS), Windows)
GOFLAGS := -v -buildmode=exe
EXECUTABLE ?= $(EXECUTABLE).exe
GO_ENV_WINDOWS := set GOOS=windows&&
else
GOFLAGS := -v
EXECUTABLE ?= $(EXECUTABLE)
GO_ENV_WINDOWS := GOOS=windows
endif
STORED_VERSION_FILE := VERSION
@@ -108,12 +111,17 @@ deps-tools: ## install tool dependencies
wait
.PHONY: lint
lint: lint-go ## lint everything
lint: lint-go lint-go-windows ## lint everything
.PHONY: lint-go
lint-go: ## lint go files
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
.PHONY: lint-go-windows
lint-go-windows: ## lint Windows go files
$(GO) install $(GOLANGCI_LINT_PACKAGE)
$(GO_ENV_WINDOWS) golangci-lint run
.PHONY: lint-go-fix
lint-go-fix: ## lint go files and fix issues
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
@@ -143,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)
@@ -210,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

116
README.md
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,26 +147,106 @@ 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.
#### External cache (`actions/cache`)
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:
If `cache.external_server` is set, you must set `cache.external_secret` to the same value on this runner and on the standalone cache server. Run the server with `gitea-runner cache-server` using a config that defines `cache.external_secret` (and matching `cache.dir` / host / port as needed). Flags `--dir`, `--host`, and `--port` on `cache-server` override the file.
```
--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.
**Shared cache across multiple runners**
Run one dedicated `gitea-runner cache-server` that all runners point at.
1. Create a config file for the cache server host:
```yaml
cache:
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:
```bash
gitea-runner -c cache-server-config.yaml cache-server
```
3. On every runner:
```yaml
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.
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
#### Official Docker image
@@ -176,6 +260,16 @@ When `container.bind_workdir` is enabled, stale task workspace directories can b
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
#### Post-task script (`runner.post_task_script`)
Optional host script that runs **after** each task's built-in cleanup (post-steps, container teardown, bind-workdir removal). Use it for extra machine housekeeping — Docker pruning, disk cleanup, and similar.
**While the script runs, the runner stops task heartbeats and stays offline from Gitea's perspective until the script exits (or hits `runner.post_task_script_timeout`, default `5m`).** A script that blocks without exiting keeps the runner from taking new work for up to that timeout. Script output goes to the runner log, not the job log; a non-zero exit is warned but does not change the job result.
On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not supported yet** as the configured path — wrap commands in a `.cmd` file instead.
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
### Example Deployments
Check out the [examples](examples) directory for sample deployment types.

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

@@ -257,6 +257,14 @@ type NewGitCloneExecutorInput struct {
Token string
OfflineMode bool
// Depth limits the clone/fetch to the given number of commits from the tip of the requested ref.
// 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
}
@@ -309,7 +317,7 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
}
}
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
r, err = cloneAtDepth(ctx, input, cloneOptions, logger)
if err != nil {
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
return nil, false, err
@@ -343,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)()
@@ -364,6 +376,16 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
pullOptions.InsecureSkipTLS = true
}
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
shallow := isShallow(r)
if shallow {
fetchOptions.Depth = 1
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
fetchOptions.RefSpecs = []config.RefSpec{spec}
}
}
if !isOfflineMode {
err = r.Fetch(&fetchOptions)
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
@@ -431,11 +453,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
reusedMsg := ""
if !isOfflineMode {
switch {
case !isOfflineMode && !shallow:
// In shallow mode the depth-limited fetch above already advanced the ref.
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
logger.Debugf("Unable to pull %s: %v", refName, err)
}
} else if reused {
case isOfflineMode && reused:
reusedMsg = " (reused in offline mode)"
}
@@ -468,3 +492,53 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return nil
}
}
// cloneAtDepth clones input.URL into input.Dir using opts.
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
if input.Depth > 0 {
for _, refName := range []plumbing.ReferenceName{
plumbing.NewBranchReferenceName(input.Ref),
plumbing.NewTagReferenceName(input.Ref),
} {
shallowOpts := opts
shallowOpts.Depth = input.Depth
shallowOpts.SingleBranch = true
shallowOpts.ReferenceName = refName
shallowOpts.Tags = git.NoTags
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
if err == nil {
return r, nil
}
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
}
}
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
}
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
}
// isShallow reports whether the local repository was cloned with a limited depth.
func isShallow(r *git.Repository) bool {
shallows, err := r.Storer.Shallow()
return err == nil && len(shallows) > 0
}
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
tagRef := plumbing.NewTagReferenceName(ref)
if _, err := r.Reference(tagRef, false); err == nil {
return config.RefSpec(fmt.Sprintf("+%s:%s", tagRef, tagRef)), true
}
remoteRef := plumbing.NewRemoteReferenceName("origin", ref)
if _, err := r.Reference(remoteRef, false); err == nil {
branchRef := plumbing.NewBranchReferenceName(ref)
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
}
return "", false
}

View File

@@ -10,12 +10,17 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"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"
)
@@ -49,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)
@@ -95,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()
@@ -380,6 +408,134 @@ 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()
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"))
for _, m := range []string{"c1", "c2", "c3"} {
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
}
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
t.Run("branch is cloned shallowly", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
})(t.Context()))
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present")
assert.Equal(t, "c3", gitHeadSubject(t, dir))
})
t.Run("tag is cloned shallowly", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: "v1", Dir: dir, Depth: 1,
})(t.Context()))
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
assert.Equal(t, 1, gitRevCount(t, dir))
assert.Equal(t, "c3", gitHeadSubject(t, dir))
})
t.Run("SHA falls back to a full clone", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
})(t.Context()))
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
})
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
})(t.Context()))
require.Equal(t, "c3", gitHeadSubject(t, dir))
// Advance main on the remote, then reuse the existing shallow clone.
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
})(t.Context()))
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
assert.Equal(t, 1, gitRevCount(t, dir))
})
}
func gitRevParse(t *testing.T, dir, rev string) string {
t.Helper()
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
require.NoError(t, err)
return strings.TrimSpace(string(out))
}
func gitRevCount(t *testing.T, dir string) int {
t.Helper()
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
require.NoError(t, err)
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
require.NoError(t, err)
return n
}
func gitHeadSubject(t *testing.T, dir string) string {
t.Helper()
out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%s").Output()
require.NoError(t, err)
return strings.TrimSpace(string(out))
}
func gitCmd(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout

View File

@@ -24,7 +24,9 @@ func JobError(ctx context.Context) error {
}
func SetJobError(ctx context.Context, err error) {
ctx.Value(jobErrorContextKeyVal).(map[string]error)["error"] = err
if container, ok := ctx.Value(jobErrorContextKeyVal).(map[string]error); ok {
container["error"] = err
}
}
// WithJobErrorContainer adds a value to the context as a container for an error

View File

@@ -12,6 +12,13 @@ import (
// LineHandler is a callback function for handling a line
type LineHandler func(line string) bool
// Flusher is implemented by writers that buffer a trailing, not-yet-terminated
// line. Callers should flush once the underlying stream has reached EOF so the
// final line (when it is not newline-terminated) is not lost.
type Flusher interface {
Flush()
}
type lineWriter struct {
buffer bytes.Buffer
handlers []LineHandler
@@ -24,6 +31,14 @@ func NewLineWriter(handlers ...LineHandler) io.Writer {
return w
}
// FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so
// callers can flush an io.Writer without knowing its concrete type.
func FlushWriter(w io.Writer) {
if f, ok := w.(Flusher); ok {
f.Flush()
}
}
func (lw *lineWriter) Write(p []byte) (n int, err error) {
pBuf := bytes.NewBuffer(p)
written := 0
@@ -44,6 +59,17 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
return written, nil
}
// Flush emits any buffered, not-yet-newline-terminated content as a final line.
// It is safe to call multiple times; subsequent calls with an empty buffer are
// no-ops.
func (lw *lineWriter) Flush() {
if lw.buffer.Len() == 0 {
return
}
lw.handleLine(lw.buffer.String())
lw.buffer.Reset()
}
func (lw *lineWriter) handleLine(line string) {
for _, h := range lw.handlers {
ok := h(line)

View File

@@ -5,6 +5,7 @@
package common
import (
"io"
"testing"
"github.com/stretchr/testify/assert"
@@ -39,3 +40,33 @@ func TestLineWriter(t *testing.T) {
assert.Equal(" and another\n", lines[2])
assert.Equal("last line\n", lines[3])
}
func TestLineWriterFlush(t *testing.T) {
lines := make([]string, 0)
lineHandler := func(s string) bool {
lines = append(lines, s)
return true
}
lineWriter := NewLineWriter(lineHandler)
assert := assert.New(t)
_, err := lineWriter.Write([]byte("complete line\npartial line without newline"))
assert.NoError(err) //nolint:testifylint // pre-existing pattern from nektos/act
// Only the newline-terminated line is emitted before flushing.
assert.Equal([]string{"complete line\n"}, lines)
// Flushing emits the buffered, not-yet-terminated trailing line.
FlushWriter(lineWriter)
assert.Equal([]string{"complete line\n", "partial line without newline"}, lines)
// Flushing again is a no-op: nothing is buffered.
FlushWriter(lineWriter)
assert.Len(lines, 2)
}
func TestFlushWriterIgnoresNonFlusher(t *testing.T) {
// FlushWriter must be a safe no-op for writers that do not buffer lines.
assert.NotPanics(t, func() { FlushWriter(io.Discard) })
}

View File

@@ -84,6 +84,12 @@ type NewDockerBuildExecutorInput struct {
Platform string
}
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
type NewDockerNetworkCreateExecutorInput struct {
EnableIPv4 *bool
EnableIPv6 *bool
}
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
type NewDockerPullExecutorInput struct {
Image string

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

@@ -14,7 +14,7 @@ import (
"github.com/moby/moby/client"
)
func NewDockerNetworkCreateExecutor(name string) common.Executor {
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
return func(ctx context.Context) error {
cli, err := GetDockerClient(ctx)
if err != nil {
@@ -37,8 +37,10 @@ func NewDockerNetworkCreateExecutor(name string) common.Executor {
}
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
Driver: "bridge",
Scope: "local",
Driver: "bridge",
Scope: "local",
EnableIPv4: opts.EnableIPv4,
EnableIPv6: opts.EnableIPv6,
})
if err != nil {
return err

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

@@ -20,6 +20,7 @@ import (
"slices"
"strconv"
"strings"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector"
@@ -34,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"
@@ -42,13 +42,25 @@ 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
// finish draining a container's output before returning, so that neither a
// cancellation (waitForCommand) nor a normal container exit (wait) truncates
// the tail of the log. It is a safety bound: in the common case the stream
// reaches EOF and the goroutine returns well before this elapses.
const drainGracePeriod = 2 * time.Second
// NewContainer creates a reference to a container
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
}
@@ -129,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(
@@ -224,11 +241,16 @@ 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.
attachDone chan struct{}
LinuxContainerEnvironmentExtensions
}
@@ -364,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 == "" {
@@ -375,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))
}
@@ -394,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.
@@ -459,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.")
}
@@ -730,7 +756,9 @@ func (cr *containerReference) tryReadGID() common.Executor {
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
logger := common.Logger(ctx)
cmdResponse := make(chan error)
// Buffered so the copy goroutine never blocks on send if the grace-period
// drain below times out and no one is left to receive.
cmdResponse := make(chan error, 1)
go func() {
var outWriter io.Writer
@@ -749,6 +777,11 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
} else {
_, err = io.Copy(outWriter, resp.Reader)
}
// Flush any buffered, not-yet-newline-terminated trailing line so the
// final line of a command's output is not lost (e.g. an error message
// printed without a trailing newline before the process exits).
common.FlushWriter(outWriter)
common.FlushWriter(errWriter)
cmdResponse <- err
}()
@@ -760,6 +793,16 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
logger.Warnf("Failed to send CTRL+C: %+s", err)
}
// Give the copy goroutine a brief grace period to drain output already
// produced by the command before we return, so cancellation does not
// truncate the tail of the log. The goroutine exits once the hijacked
// stream is closed by resp.Close() in the caller's defer.
select {
case <-cmdResponse:
case <-time.After(drainGracePeriod):
logger.Warn("Timed out draining command output after cancellation")
}
// we return the context canceled error to prevent other steps
// from executing
return ctx.Err()
@@ -945,14 +988,23 @@ func (cr *containerReference) attach() common.Executor {
if errWriter == nil {
errWriter = os.Stderr
}
done := make(chan struct{})
cr.attachDone = done
go func() {
defer close(done)
var copyErr error
if !isTerminal || os.Getenv("NORAW") != "" {
_, err = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
_, copyErr = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
} else {
_, err = io.Copy(outWriter, out.Reader)
_, copyErr = io.Copy(outWriter, out.Reader)
}
if err != nil {
common.Logger(ctx).Error(err)
// Flush any buffered, not-yet-newline-terminated trailing line once
// the stream reaches EOF, so the final line of the container's
// output is not lost when it is not newline-terminated.
common.FlushWriter(outWriter)
common.FlushWriter(errWriter)
if copyErr != nil {
common.Logger(ctx).Error(copyErr)
}
}()
return nil
@@ -991,6 +1043,18 @@ func (cr *containerReference) wait() common.Executor {
logger.Debugf("Return status: %v", statusCode)
// The container has exited; wait for the attach() streaming goroutine to
// finish draining and flushing its output before returning, so the tail
// of the log is not lost. Bounded so a stuck stream cannot hang the step.
if cr.attachDone != nil {
select {
case <-cr.attachDone:
case <-time.After(drainGracePeriod):
logger.Warn("Timed out draining container output")
}
cr.attachDone = nil
}
if statusCode == 0 {
return nil
}

View File

@@ -8,6 +8,7 @@ import (
"bufio"
"bytes"
"context"
"encoding/binary"
"errors"
"io"
"net"
@@ -20,6 +21,7 @@ import (
"gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/api/types/container"
mobyclient "github.com/moby/moby/client"
"github.com/sirupsen/logrus/hooks/test"
@@ -89,6 +91,11 @@ func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
}
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
}
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerWaitResult)
@@ -109,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
}
@@ -206,6 +218,71 @@ func TestDockerExecFailure(t *testing.T) {
client.AssertExpectations(t)
}
// stdcopyFrame wraps payload in a single Docker multiplexed-stream frame, the
// format StdCopy expects: an 8-byte header (stream type + 4-byte big-endian
// length) followed by the payload.
func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
b := make([]byte, 8+len(payload))
b[0] = byte(stream)
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
copy(b[8:], payload)
return b
}
// TestDockerAttachFlushesTrailingLine verifies that wait() blocks until the
// attach() streaming goroutine has drained and flushed the container's output,
// so a final line without a trailing newline is not lost.
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
ctx := context.Background()
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
var lines []string
logWriter := common.NewLineWriter(func(s string) bool {
lines = append(lines, s)
return true
})
client := &mockDockerClient{}
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
Return(mobyclient.ContainerAttachResult{
HijackedResponse: mobyclient.HijackedResponse{
Conn: &mockConn{},
Reader: bufio.NewReader(framed),
},
}, nil)
statusCh := make(chan container.WaitResponse, 1)
statusCh <- container.WaitResponse{StatusCode: 0}
errCh := make(chan error, 1)
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
Return(mobyclient.ContainerWaitResult{
Result: (<-chan container.WaitResponse)(statusCh),
Error: (<-chan error)(errCh),
})
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
Stdout: logWriter,
Stderr: logWriter,
},
}
require.NoError(t, cr.attach()(ctx))
require.NoError(t, cr.wait()(ctx))
// wait() must have blocked until the goroutine drained AND flushed; the
// trailing, non-newline-terminated line must therefore be present. Reading
// lines here is race-free because wait() synchronizes on attachDone, which
// the goroutine closes after the final append.
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
client.AssertExpectations(t)
}
func TestDockerWaitFailure(t *testing.T) {
ctx := context.Background()
@@ -309,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

@@ -61,7 +61,7 @@ func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
}
}
func NewDockerNetworkCreateExecutor(name string) common.Executor {
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
return func(ctx context.Context) error {
return nil
}

View File

@@ -17,12 +17,14 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector"
"gitea.com/gitea/runner/act/lookpath"
"gitea.com/gitea/runner/internal/pkg/process"
"github.com/go-git/go-billy/v5/helper/polyfill"
"github.com/go-git/go-billy/v5/osfs"
@@ -42,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 {
@@ -261,7 +282,7 @@ func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
cmd.Stdin = tty
cmd.Stdout = tty
cmd.Stderr = tty
cmd.SysProcAttr = getSysProcAttr(cmdline, true)
cmd.SysProcAttr = process.SysProcAttr(cmdline, true)
return ppty, tty, nil
}
@@ -321,30 +342,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
cmd.Env = envList
cmd.Stderr = e.StdOut
cmd.Dir = wd
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
// On Windows a step often launches a process tree (a shell that starts a
// child which spawns further GUI or background processes). The default
// context cancellation only kills the direct child, leaving the rest of the
// tree running; and because the orphans inherit cmd's stdout/stderr pipe,
// cmd.Wait() would block forever, hanging the runner. Kill the whole tree
// via a Job Object on cancellation, and bound the wait so a leftover pipe
// writer can never hang Wait indefinitely.
var killer atomic.Pointer[processKiller]
if runtime.GOOS == "windows" {
cmd.Cancel = func() error {
if k := killer.Load(); k != nil {
return k.Kill()
}
if cmd.Process != nil {
return cmd.Process.Kill()
}
return nil
}
// Once the step process has exited, give its I/O pipes at most this long
// to drain before Wait force-closes them and returns (Go's WaitDelay).
cmd.WaitDelay = 10 * time.Second
}
// 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
var tty *os.File
@@ -375,17 +377,15 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
if err := cmd.Start(); err != nil {
return err
}
if runtime.GOOS == "windows" {
// Assign the started process to a Job Object so cmd.Cancel can kill the
// whole descendant tree. Children spawned afterwards are auto-included.
// On failure (e.g. nested-job restrictions) we fall back to the default
// single-process kill; WaitDelay + end-of-job cleanup still apply.
if k, kerr := newProcessKiller(cmd.Process); kerr != nil {
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
} else {
killer.Store(k)
defer k.Close()
}
// 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 {
defer k.Close()
}
err = cmd.Wait()
if err != nil {
@@ -429,6 +429,23 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
return parseEnvFile(e, srcPath, env)
}
// removeAll is a var so tests can substitute a blocking stub.
var removeAll = os.RemoveAll
// 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) }()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func removePathWithRetry(ctx context.Context, path string) error {
if path == "" {
return nil
@@ -448,25 +465,23 @@ func removePathWithRetry(ctx context.Context, path string) error {
case <-time.After(delay):
}
}
lastErr = os.RemoveAll(path)
lastErr = removeAllWithContext(ctx, path)
if lastErr == nil {
return nil
}
if errors.Is(lastErr, context.DeadlineExceeded) {
return lastErr
}
}
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 {
@@ -502,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)
@@ -531,25 +545,67 @@ 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 teardown phase so one stalled delete cannot
// wedge the runner slot. A var so tests can shrink it.
var hostCleanupTimeout = 30 * time.Second
// 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() {
defer close(done)
fn()
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-done:
return nil
case <-timer.C:
return context.DeadlineExceeded
}
}
func (e *HostEnvironment) Remove() common.Executor {
return func(ctx context.Context) error {
// Ensure any lingering child processes are ended before attempting
// to remove the workspace (Windows file locks otherwise prevent cleanup).
logger := common.Logger(ctx)
// 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.
// 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 {
e.CleanUp()
logger.Debugf("running host environment cleanup callback")
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
logger.Warnf("host environment cleanup did not finish within %s; continuing job completion, scratch state may be leaked and is reclaimed by the idle stale-dir sweep", hostCleanupTimeout)
} else {
logger.Debugf("host environment cleanup callback finished")
}
}
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
// which absorb Windows file-handle release lag after the kill above.
rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second)
rmCtx, rmCancel := context.WithTimeout(context.Background(), hostCleanupTimeout)
defer rmCancel()
logger := common.Logger(ctx)
var errs []error
if err := removePathWithRetry(rmCtx, e.Path); err != nil {
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
@@ -561,7 +617,13 @@ func (e *HostEnvironment) Remove() common.Executor {
errs = append(errs, err)
}
}
return errors.Join(errs...)
for _, err := range errs {
if !errors.Is(err, context.DeadlineExceeded) {
return errors.Join(errs...)
}
}
// Teardown timed out; warned above. Do not fail job completion over it.
return nil
}
}

View File

@@ -15,6 +15,7 @@ import (
"runtime"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
@@ -188,6 +189,118 @@ func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
assert.ErrorIs(t, err, os.ErrNotExist)
}
func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
release := make(chan struct{})
stubDone := make(chan struct{})
orig := removeAll
removeAll = func(string) error {
defer close(stubDone)
<-release
return nil
}
// removeAllWithContext intentionally leaks the delete goroutine on timeout,
// and that goroutine still references removeAll. Unblock it and wait for it
// to return before restoring the var, so the restore can't race the read.
t.Cleanup(func() {
close(release)
<-stubDone
removeAll = orig
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := removeAllWithContext(ctx, t.TempDir())
require.ErrorIs(t, err, context.DeadlineExceeded)
}
// TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp guards against a stalled
// CleanUp callback (e.g. an os.RemoveAll blocked by an AV/EDR filter driver or
// an unresponsive mount) wedging the runner slot forever at "Cleaning up
// container". Remove must time out the callback and complete job teardown.
func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) {
// Keep the suite fast: shrink the per-phase teardown timeout for this test.
orig := hostCleanupTimeout
hostCleanupTimeout = 100 * time.Millisecond
t.Cleanup(func() { hostCleanupTimeout = orig })
logger := logrus.New()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir()
path := filepath.Join(base, "misc", "hostexecutor")
require.NoError(t, os.MkdirAll(path, 0o700))
release := make(chan struct{})
t.Cleanup(func() { close(release) }) // unblock the leaked goroutine at test end
e := &HostEnvironment{
Path: path,
CleanUp: func() {
<-release // simulate a delete syscall stuck indefinitely
},
StdOut: os.Stdout,
}
done := make(chan error, 1)
go func() { done <- e.Remove()(ctx) }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(10 * time.Second):
t.Fatal("Remove() hung on a stuck CleanUp callback")
}
}
// TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval guards against a
// stalled os.RemoveAll on the misc/workspace paths (same AV/EDR wedge as
// #1023) wedging job completion after the CleanUp callback has already timed
// out or finished.
func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) {
origTimeout := hostCleanupTimeout
hostCleanupTimeout = 100 * time.Millisecond
t.Cleanup(func() { hostCleanupTimeout = origTimeout })
release := make(chan struct{})
stubDone := make(chan struct{})
origRemoveAll := removeAll
removeAll = func(string) error {
defer close(stubDone)
<-release
return nil
}
// The stuck delete goroutine outlives the timed-out Remove and still reads
// removeAll; unblock it and wait before restoring to avoid a restore/read race.
t.Cleanup(func() {
close(release)
<-stubDone
removeAll = origRemoveAll
})
logger := logrus.New()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir()
path := filepath.Join(base, "misc", "hostexecutor")
require.NoError(t, os.MkdirAll(path, 0o700))
e := &HostEnvironment{
Path: path,
StdOut: os.Stdout,
}
done := make(chan error, 1)
go func() { done <- e.Remove()(ctx) }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(10 * time.Second):
t.Fatal("Remove() hung on a stuck path removal")
}
}
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
t.Run("single dir", func(t *testing.T) {
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})

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

@@ -1,19 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package container
import "os"
// processKiller is a no-op on non-Windows platforms. The Job Object based
// tree-kill is only wired in on Windows (see exec()); elsewhere the default
// exec.CommandContext cancellation and Setpgid handling apply.
type processKiller struct{}
func newProcessKiller(_ *os.Process) (*processKiller, error) { return &processKiller{}, nil }
func (k *processKiller) Kill() error { return nil }
func (k *processKiller) Close() error { return nil }

View File

@@ -1,71 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"os"
"golang.org/x/sys/windows"
)
// processKiller terminates a step process together with its entire descendant
// tree via a Windows Job Object.
//
// Background: a step often launches a process tree (a shell that starts a
// child which in turn spawns further GUI or background processes). The default
// exec.CommandContext cancellation only kills the direct child, so cancelling a
// job left the rest of the tree running. Because those orphans inherited the
// step's stdout/stderr pipe, cmd.Wait() also blocked forever and the runner hung.
//
// Assigning the step process to a Job Object lets us kill the whole tree
// atomically on cancellation (TerminateJobObject), which also closes the
// inherited pipe handles so cmd.Wait() can return.
type processKiller struct {
job windows.Handle
}
// newProcessKiller creates a Job Object and assigns p (an already-started
// process) to it. Children spawned by p afterwards are automatically part of
// the job. The job does NOT use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so closing
// the handle on normal completion does not kill legitimate background
// processes; the tree is only torn down by an explicit Kill (cancellation).
func newProcessKiller(p *os.Process) (*processKiller, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return nil, err
}
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
if err != nil {
windows.CloseHandle(job)
return nil, err
}
defer windows.CloseHandle(h)
if err := windows.AssignProcessToJobObject(job, h); err != nil {
windows.CloseHandle(job)
return nil, err
}
return &processKiller{job: job}, nil
}
// Kill terminates every process currently assigned to the job (the step process
// and all of its descendants).
func (k *processKiller) Kill() error {
if k == nil || k.job == 0 {
return nil
}
return windows.TerminateJobObject(k.job, 1)
}
// Close releases the job handle. It does not terminate the processes.
func (k *processKiller) Close() error {
if k == nil || k.job == 0 {
return nil
}
h := k.job
k.job = 0
return windows.CloseHandle(h)
}

View File

@@ -8,23 +8,10 @@ package container
import (
"os"
"syscall"
"github.com/creack/pty"
)
func getSysProcAttr(_ string, tty bool) *syscall.SysProcAttr {
if tty {
return &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
}
}
return &syscall.SysProcAttr{
Setpgid: true,
}
}
func openPty() (*os.File, *os.File, error) {
return pty.Open()
}

View File

@@ -7,15 +7,8 @@ package container
import (
"errors"
"os"
"syscall"
)
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
return &syscall.SysProcAttr{
Setpgid: true,
}
}
func openPty() (*os.File, *os.File, error) {
return nil, nil, errors.New("Unsupported")
}

View File

@@ -7,15 +7,8 @@ package container
import (
"errors"
"os"
"syscall"
)
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
return &syscall.SysProcAttr{
Rfork: syscall.RFNOTEG,
}
}
func openPty() (*os.File, *os.File, error) {
return nil, nil, errors.New("Unsupported")
}

View File

@@ -7,13 +7,8 @@ package container
import (
"errors"
"os"
"syscall"
)
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
return &syscall.SysProcAttr{CmdLine: cmdLine, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
}
func openPty() (*os.File, *os.File, error) {
return nil, nil, errors.New("Unsupported")
}

View File

@@ -266,7 +266,7 @@ func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pr
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
for _, needs := range jobNeeds {
if jobs[needs].Result != "success" {
if jobs[needs].NeedsResult() != "success" {
return false, nil
}
}
@@ -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
@@ -283,7 +292,7 @@ func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pr
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
for _, needs := range jobNeeds {
if jobs[needs].Result == "failure" {
if jobs[needs].NeedsResult() == "failure" {
return true, nil
}
}
@@ -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)
}
}

View File

@@ -62,7 +62,7 @@ func LookPath2(file string, lenv Env) (string, error) {
var exts []string
x := lenv.Getenv(`PATHEXT`)
if x != "" {
for _, e := range strings.Split(strings.ToLower(x), `;`) {
for e := range strings.SplitSeq(strings.ToLower(x), `;`) {
if e == "" {
continue
}

View File

@@ -76,17 +76,19 @@ func (a ActionRunsUsing) IsComposite() bool {
// ActionRuns are a field in Action
type ActionRuns struct {
Using ActionRunsUsing `yaml:"using"`
Env map[string]string `yaml:"env"`
Main string `yaml:"main"`
Pre string `yaml:"pre"`
PreIf string `yaml:"pre-if"`
Post string `yaml:"post"`
PostIf string `yaml:"post-if"`
Image string `yaml:"image"`
Entrypoint string `yaml:"entrypoint"`
Args []string `yaml:"args"`
Steps []Step `yaml:"steps"`
Using ActionRunsUsing `yaml:"using"`
Env map[string]string `yaml:"env"`
Main string `yaml:"main"`
Pre string `yaml:"pre"`
PreIf string `yaml:"pre-if"`
Post string `yaml:"post"`
PostIf string `yaml:"post-if"`
Image string `yaml:"image"`
PreEntrypoint string `yaml:"pre-entrypoint"`
Entrypoint string `yaml:"entrypoint"`
PostEntrypoint string `yaml:"post-entrypoint"`
Args []string `yaml:"args"`
Steps []Step `yaml:"steps"`
}
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.

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

@@ -0,0 +1,82 @@
// 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)
}
}
func TestReadActionDockerEntrypoints(t *testing.T) {
action, err := ReadAction(strings.NewReader(`
runs:
using: docker
image: Dockerfile
pre-entrypoint: pre.sh
post-entrypoint: post.sh
`))
if err != nil {
t.Fatal(err)
}
if action.Runs.PreEntrypoint != "pre.sh" {
t.Fatalf("pre-entrypoint = %q, want pre.sh", action.Runs.PreEntrypoint)
}
if action.Runs.PostEntrypoint != "post.sh" {
t.Fatalf("post-entrypoint = %q, want post.sh", action.Runs.PostEntrypoint)
}
}

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

@@ -190,23 +190,52 @@ func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
// Job is the structure of one job in a workflow
type Job struct {
Name string `yaml:"name"`
RawNeeds yaml.Node `yaml:"needs"`
RawRunsOn yaml.Node `yaml:"runs-on"`
Env yaml.Node `yaml:"env"`
If yaml.Node `yaml:"if"`
Steps []*Step `yaml:"steps"`
TimeoutMinutes string `yaml:"timeout-minutes"`
Services map[string]*ContainerSpec `yaml:"services"`
Strategy *Strategy `yaml:"strategy"`
RawContainer yaml.Node `yaml:"container"`
Defaults Defaults `yaml:"defaults"`
Outputs map[string]string `yaml:"outputs"`
Uses string `yaml:"uses"`
With map[string]any `yaml:"with"`
RawSecrets yaml.Node `yaml:"secrets"`
RawPermissions yaml.Node `yaml:"permissions"`
Result string
Name string `yaml:"name"`
RawNeeds yaml.Node `yaml:"needs"`
RawRunsOn yaml.Node `yaml:"runs-on"`
Env yaml.Node `yaml:"env"`
If yaml.Node `yaml:"if"`
Steps []*Step `yaml:"steps"`
TimeoutMinutes string `yaml:"timeout-minutes"`
RawContinueOnError string `yaml:"continue-on-error"`
Services map[string]*ContainerSpec `yaml:"services"`
Strategy *Strategy `yaml:"strategy"`
RawContainer yaml.Node `yaml:"container"`
Defaults Defaults `yaml:"defaults"`
Outputs map[string]string `yaml:"outputs"`
Uses string `yaml:"uses"`
With map[string]any `yaml:"with"`
RawSecrets yaml.Node `yaml:"secrets"`
RawPermissions yaml.Node `yaml:"permissions"`
Result string
// Runtime fields set during execution (not from YAML):
ContinueOnError bool // true when all failing matrix combinations had continue-on-error=true
hasFirmFailure bool // true once any combination failed without continue-on-error
}
// SetContinueOnError records whether this combination's failure should not fail the workflow.
// Must be called under the job lock. Safe across parallel matrix combinations.
func (j *Job) SetContinueOnError(continueOnErr bool) {
if continueOnErr {
if !j.hasFirmFailure {
j.ContinueOnError = true
}
} else {
j.hasFirmFailure = true
j.ContinueOnError = false
}
}
// NeedsResult returns the job result as seen by dependent jobs through the
// `needs` context. A job that failed but was tolerated via continue-on-error
// reports "success" to its dependents, matching GitHub: such a failure must not
// block jobs gated on the default `if: success()`, even though the overall
// workflow run is still marked as failed.
func (j *Job) NeedsResult() string {
if j.Result == "failure" && j.ContinueOnError {
return "success"
}
return j.Result
}
// Strategy for the job

View File

@@ -5,6 +5,7 @@
package model
import (
"fmt"
"strings"
"testing"
@@ -32,6 +33,216 @@ func TestStepCloneIsolatesMutableFields(t *testing.T) {
assert.Equal(t, "original", orig.With["arg"], "With map must not be shared with the clone")
}
// TestJobNeedsResult guards the continue-on-error semantics exposed to dependent
// jobs through the `needs` context: a failed-but-tolerated job reports "success"
// so it does not block dependents gated on the default `if: success()`, matching
// GitHub. A firm failure and any non-failure result are reported verbatim.
func TestJobNeedsResult(t *testing.T) {
cases := []struct {
name string
result string
continueOnError bool
want string
}{
{"tolerated failure reports success", "failure", true, "success"},
{"firm failure reports failure", "failure", false, "failure"},
{"success is unchanged", "success", false, "success"},
{"success with continue-on-error is unchanged", "success", true, "success"},
{"empty result is unchanged", "", true, ""},
{"skipped is unchanged", "skipped", true, "skipped"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
j := &Job{Result: tc.result, ContinueOnError: tc.continueOnError}
assert.Equal(t, tc.want, j.NeedsResult())
})
}
}
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
@@ -926,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

@@ -6,7 +6,9 @@ package runner
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -205,7 +207,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
if remoteAction == nil {
location = containerActionDir
}
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil)
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil, stepStageMain)
case x.IsComposite():
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err
@@ -272,8 +274,38 @@ func removeGitIgnore(ctx context.Context, directory string) error {
return nil
}
// dockerActionImageTag derives the local docker image tag used when an action
// is built from a Dockerfile.
//
// For Gitea: a local action (`uses: ./` or `uses: ./path`) has an actionName
// that is the workspace-relative path of the action. That path is identical
// across repositories (e.g. "./" for a self-referencing action), so without
// namespacing, every repository's local docker action would build and reuse the
// same `act-dockeraction:latest` image on a shared docker daemon. A subsequent
// repository would then silently run the image built for an earlier one.
// Including the repository keeps the tag stable for caching within a repository
// while preventing cross-repository collisions.
// See https://gitea.com/gitea/runner/issues/1039.
func dockerActionImageTag(repository, actionName string, localAction bool) string {
name := actionName
if localAction {
name = path.Join(repository, actionName)
}
// The human-readable name is sanitized by collapsing every non-alphanumeric character to "-".
sanitized := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(name, "-")
if localAction {
// For local actions a short hash of the raw repository and action path is appended so the tag stays unique per repository.
sum := sha256.Sum256([]byte(repository + "\x00" + actionName))
sanitized += "-" + hex.EncodeToString(sum[:])[:12]
}
// "-dockeraction" ensures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
image := fmt.Sprintf("%s-dockeraction:%s", sanitized, "latest")
image = "act-" + strings.TrimLeft(image, "-")
return strings.ToLower(image)
}
// TODO: break out parts of function to reduce complexicity
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
logger := common.Logger(ctx)
rc := step.getRunContext()
action := step.getActionModel()
@@ -286,10 +318,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
// Apply forcePull only for prebuild docker images
forcePull = rc.Config.ForcePull
} else {
// "-dockeraction" enshures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest")
image = "act-" + strings.TrimLeft(image, "-")
image = strings.ToLower(image)
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction)
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
@@ -357,16 +386,9 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
cmd = action.Runs.Args
evalDockerArgs(ctx, step, action, &cmd)
}
entrypoint := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"]))
if len(entrypoint) == 0 {
if action.Runs.Entrypoint != "" {
entrypoint, err = shellquote.Split(action.Runs.Entrypoint)
if err != nil {
return err
}
} else {
entrypoint = nil
}
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
if err != nil {
return err
}
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
return common.NewPipelineExecutor(
@@ -376,10 +398,34 @@ 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)
}
// dockerEntrypoint returns the entrypoint the action's image runs with for the given
// stage. Only the main stage honours the `entrypoint` input.
func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvaluator, stage stepStage) ([]string, error) {
runs := step.getActionModel().Runs
var entrypoint string
switch stage {
case stepStagePre:
entrypoint = runs.PreEntrypoint
case stepStagePost:
entrypoint = runs.PostEntrypoint
default:
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
return fields, nil
}
entrypoint = runs.Entrypoint
}
if entrypoint == "" {
return nil, nil
}
return shellquote.Split(entrypoint)
}
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
rc := step.getRunContext()
stepModel := step.getStepModel()
@@ -530,44 +576,57 @@ func hasPreStep(step actionStep) common.Conditional {
return action.Runs.Using.IsComposite() ||
(action.Runs.Using.IsNode() &&
action.Runs.Pre != "") ||
(action.Runs.Using.IsDocker() &&
action.Runs.PreEntrypoint != "") ||
(action.Runs.Using == model.ActionRunsUsingGo &&
action.Runs.Pre != "")
}
}
// actionStagePaths resolves where a step's action lives and where the job container sees
// it, for the pre and post stage.
func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, containerActionDir string) {
rc := step.getRunContext()
stepModel := step.getStepModel()
if _, ok := step.(*stepActionRemote); ok {
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
actionPath = newRemoteAction(stepModel.Uses).Path
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
}
actionName, containerActionDir = getContainerActionPaths(stepModel, path.Join(actionDir, actionPath), rc)
return actionDir, actionPath, actionName, containerActionDir
}
// execDockerActionStage runs a docker action's image for its pre or post stage.
func execDockerActionStage(ctx context.Context, step actionStep, stage stepStage) error {
actionDir, actionPath, actionName, containerActionDir := actionStagePaths(step)
_, remote := step.(*stepActionRemote)
location := containerActionDir
if remote {
location = path.Join(actionDir, actionPath)
}
return execAsDocker(ctx, step, actionName, actionDir, location, !remote, stage)
}
func runPreStep(step actionStep) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Debugf("run pre step for '%s'", step.getStepModel())
rc := step.getRunContext()
stepModel := step.getStepModel()
action := step.getActionModel()
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
x := action.Runs.Using
switch {
case x.IsNode():
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
// todo: refactor into step
var actionDir string
var actionPath string
if _, ok := step.(*stepActionRemote); ok {
actionPath = newRemoteAction(stepModel.Uses).Path
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
actionPath = ""
}
var actionLocation string
if actionPath != "" {
actionLocation = path.Join(actionDir, actionPath)
} else {
actionLocation = actionDir
}
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err
@@ -580,6 +639,12 @@ func runPreStep(step actionStep) common.Executor {
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
return execDockerActionStage(ctx, step, stepStagePre)
case x.IsComposite():
if step.getCompositeSteps() == nil {
step.getCompositeRunContext(ctx)
@@ -593,25 +658,6 @@ func runPreStep(step actionStep) common.Executor {
case x == model.ActionRunsUsingGo:
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
// todo: refactor into step
var actionDir string
var actionPath string
if _, ok := step.(*stepActionRemote); ok {
actionPath = newRemoteAction(stepModel.Uses).Path
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
actionPath = ""
}
var actionLocation string
if actionPath != "" {
actionLocation = path.Join(actionDir, actionPath)
} else {
actionLocation = actionDir
}
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err
@@ -664,6 +710,8 @@ func hasPostStep(step actionStep) common.Conditional {
return action.Runs.Using.IsComposite() ||
(action.Runs.Using.IsNode() &&
action.Runs.Post != "") ||
(action.Runs.Using.IsDocker() &&
action.Runs.PostEntrypoint != "") ||
(action.Runs.Using == model.ActionRunsUsingGo &&
action.Runs.Post != "")
}
@@ -675,28 +723,9 @@ func runPostStep(step actionStep) common.Executor {
logger.Debugf("run post step for '%s'", step.getStepModel())
rc := step.getRunContext()
stepModel := step.getStepModel()
action := step.getActionModel()
// todo: refactor into step
var actionDir string
var actionPath string
if _, ok := step.(*stepActionRemote); ok {
actionPath = newRemoteAction(stepModel.Uses).Path
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
actionPath = ""
}
var actionLocation string
if actionPath != "" {
actionLocation = path.Join(actionDir, actionPath)
} else {
actionLocation = actionDir
}
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
x := action.Runs.Using
switch {
@@ -711,6 +740,11 @@ func runPostStep(step actionStep) common.Executor {
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
populateEnvsFromSavedState(step.getEnv(), step, rc)
return execDockerActionStage(ctx, step, stepStagePost)
case x.IsComposite():
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err

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, stepStageMain))
cm.AssertExpectations(t)
assert.Equal(t, tc.removes, removes)
}
}
func TestActionRunner(t *testing.T) {
table := []struct {
name string
@@ -426,7 +465,7 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
defer cancel()
done := make(chan error, 1)
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false) }()
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false, stepStageMain) }()
select {
case <-innerEntered:
@@ -455,3 +494,133 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
}
}
func TestDockerActionImageTag(t *testing.T) {
// Remote actions already carry a unique, ref-scoped actionName (the uses
// hash), so the tag must be left untouched for backwards compatibility.
assert.Equal(t,
"act-abc123-dockeraction:latest",
dockerActionImageTag("owner/repo", "abc123", false),
)
// Local actions keep a human-readable, repository-namespaced prefix and gain a short hash suffix that makes the tag unique per (repository, actionName).
// See https://gitea.com/gitea/runner/issues/1039.
assert.Equal(t,
"act-owner-repo-baca2daaa2fe-dockeraction:latest",
dockerActionImageTag("owner/repo", "./", true),
)
assert.Equal(t,
"act-owner-repo-sub-e847b61255a8-dockeraction:latest",
dockerActionImageTag("owner/repo", "./sub", true),
)
// Sanitizing every non-alphanumeric character to "-" is lossy, so distinct inputs can collapse to the same readable prefix.
// The hash suffix must keep such cases apart, otherwise an image built for one repository is reused for another.
collisions := [][2]struct {
repoName string
actionName string
}{
// Two different repositories, both `uses: ./`: "a/b-c" and "a-b/c" both sanitize to "a-b-c".
{{"a/b-c", "./"}, {"a-b/c", "./"}},
// A repository's root action vs another repository's sub-path action:
// "owner/repo-a" + "./" and "owner/repo" + "./a" both sanitize to "owner-repo-a".
{{"owner/repo-a", "./"}, {"owner/repo", "./a"}},
}
for _, c := range collisions {
assert.NotEqual(t,
dockerActionImageTag(c[0].repoName, c[0].actionName, true),
dockerActionImageTag(c[1].repoName, c[1].actionName, true),
"local docker action tags must differ for %q/%q vs %q/%q",
c[0].repoName, c[0].actionName, c[1].repoName, c[1].actionName,
)
}
// Distinct local actions within the same repository keep distinct tags.
assert.NotEqual(t,
dockerActionImageTag("owner/repo", "./", true),
dockerActionImageTag("owner/repo", "./sub", true),
)
}
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
// and runs.env, and the `entrypoint` input applies to the main stage alone.
func TestExecAsDockerStageEntrypoint(t *testing.T) {
orig := ContainerNewContainer
defer func() { ContainerNewContainer = orig }()
for _, tc := range []struct {
name string
stage stepStage
wantEntrypoint []string
}{
{
name: "main stage prefers the entrypoint input",
stage: stepStageMain,
wantEntrypoint: []string{"input.sh"},
},
{
name: "pre stage uses runs.pre-entrypoint",
stage: stepStagePre,
wantEntrypoint: []string{"pre.sh", "--verbose"},
},
{
name: "post stage uses runs.post-entrypoint",
stage: stepStagePost,
wantEntrypoint: []string{"post.sh"},
},
} {
t.Run(tc.name, func(t *testing.T) {
cm := &containerMock{}
var input *container.NewContainerInput
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
input = in
return cm
}
step := &stepActionRemote{
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}},
RunContext: &RunContext{
Config: &Config{},
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",
PreEntrypoint: "pre.sh --verbose",
Entrypoint: "main.sh",
PostEntrypoint: "post.sh",
Args: []string{"hello"},
Env: map[string]string{"MY_VAR": "world"},
}},
env: map[string]string{},
}
cm.On("Pull", false).Return(func(context.Context) error { return nil })
cm.On("Remove").Return(func(context.Context) error { 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, tc.stage))
require.NotNil(t, input)
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
assert.Equal(t, []string{"hello"}, input.Cmd)
assert.Contains(t, input.Env, "MY_VAR=world")
})
}
}
func TestDockerActionHasPreAndPostStep(t *testing.T) {
newStep := func(runs model.ActionRuns) actionStep {
return &stepActionRemote{action: &model.Action{Runs: runs}}
}
ctx := context.Background()
assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
withStages := model.ActionRuns{Using: "docker", Image: "Dockerfile", PreEntrypoint: "pre.sh", PostEntrypoint: "post.sh"}
assert.True(t, hasPreStep(newStep(withStages))(ctx))
assert.True(t, hasPostStep(newStep(withStages))(ctx))
}

View File

@@ -0,0 +1,312 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
// TestCancelledJobStatusEnablesAlwaysAndCancelledSteps verifies that once a job is
// cancelled, getJobContext reports the "cancelled" status so the step `if` functions
// evaluate the way GitHub Actions does: cancelled()/always() are true, success()/failure()
// are false. A step that defaults to success() is therefore skipped while an always() step
// still runs. Before the fix the status could only ever be success/failure, so cancelled()
// was structurally impossible and cancel-only cleanup steps never ran.
func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
rc.markCancelled()
// The core fix: the job status context now reports "cancelled" instead of being
// pinned to success/failure.
jobCtx := rc.getJobContext()
require.Equal(t, "cancelled", jobCtx.Status)
// Feed that status through the step-context expression functions, which is what a
// step `if` evaluates. On a cancelled job only always()/cancelled() are true.
interp := exprparser.NewInterpeter(
&exprparser.EvaluationEnvironment{Job: jobCtx},
exprparser.Config{Context: "step"},
)
for expr, want := range map[string]bool{
"cancelled()": true,
"always()": true,
"success()": false,
"failure()": false,
"!cancelled()": false,
} {
got, err := interp.Evaluate(expr, exprparser.DefaultStatusCheckNone)
require.NoErrorf(t, err, "Evaluate(%q)", expr)
assert.Equalf(t, want, got, "Evaluate(%q) on a cancelled job", expr)
}
// A step without an `if` defaults to success() and must be skipped on cancel,
// while an `if: always()` step must still run.
disabled, err := interp.Evaluate("", exprparser.DefaultStatusCheckSuccess)
require.NoError(t, err)
assert.Equal(t, false, disabled, "default-success step must be skipped on a cancelled job")
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
require.NoError(t, err)
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
}
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
// not abandon the remaining steps when the run is cancelled mid-pipeline. The later step
// still runs (so a main-stage always() step is reached), it runs under a fresh,
// non-cancelled context, and the job is marked cancelled. The interrupt error is still
// propagated so callers up the chain see the cancellation.
func TestMainStepsExecutorRunsAlwaysStepsAfterCancel(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var ran []string
var laterStepCtxErr error
steps := []common.Executor{
func(_ context.Context) error {
ran = append(ran, "step1")
cancel() // server cancellation lands while step1 runs
return nil
},
func(c context.Context) error {
ran = append(ran, "always-step")
laterStepCtxErr = c.Err()
return nil
},
}
err := newMainStepsExecutor(rc, steps)(ctx)
require.ErrorIs(t, err, context.Canceled, "interrupt error is propagated")
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after cancel")
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-cancelled context")
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
}
// TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps guards the timeout path's symmetry with the cancel path.
// When the job deadline (timeout-minutes) lands in the gap between two steps, the job must be marked as failed (not cancelled),
// so always()/failure() cleanup steps run while default success() steps skip, and so the timed-out job is not reported as success.
func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
// A short deadline that we let elapse between steps, so no step records the error itself.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
var ran []string
var laterStepCtxErr error
steps := []common.Executor{
func(c context.Context) error {
ran = append(ran, "step1")
// Block until the job deadline elapses, then return cleanly: the interrupt lands in the loop's between-steps check, not inside a step.
<-c.Done()
return nil
},
func(c context.Context) error {
ran = append(ran, "always-step")
laterStepCtxErr = c.Err()
return nil
},
}
err := newMainStepsExecutor(rc, steps)(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded, "the timeout error is propagated")
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after a timeout")
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-expired context")
assert.True(t, rc.jobFailed, "a job timeout marks the job failed")
assert.False(t, rc.jobCancelled, "a timeout is not a cancellation")
// The status the real main-step `if` evaluation sees: "failure", so default success() steps skip while always()/failure() steps run.
assert.Equal(t, "failure", rc.getJobContext().Status)
}
// TestStepsExecutorRunsMainStepsAfterPreCancel verifies that a cancellation landing during the
// pre phase does not abandon the main steps: newStepsExecutor still runs the main-steps executor,
// so a main-stage always()/cancelled() step is reached (under a fresh, non-cancelled context),
// the job is marked cancelled, and the cancellation is propagated. Before the fix the `.Then(...)`
// short-circuit skipped the main steps entirely when a pre step was cancelled.
func TestStepsExecutorRunsMainStepsAfterPreCancel(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var ran []string
var mainStepCtxErr error
preSteps := []common.Executor{
func(_ context.Context) error {
ran = append(ran, "pre1")
cancel() // server cancellation lands during the pre phase
return nil
},
}
steps := []common.Executor{
func(c context.Context) error {
ran = append(ran, "always-step")
mainStepCtxErr = c.Err()
return nil
},
}
err := newStepsExecutor(rc, preSteps, steps)(ctx)
require.ErrorIs(t, err, context.Canceled, "the cancellation is propagated")
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-phase cancel")
require.NoError(t, mainStepCtxErr, "the main step runs under a fresh, non-cancelled context")
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
}
// TestStepsExecutorRunsMainStepsAfterPreFailure verifies that a failing pre step does not abandon
// the main steps: they still run (so a main-stage always()/failure() step is reached), and the
// pre-step error is propagated so the job is reported as failed. The main steps' own `if`
// evaluation is what skips success()-default steps, so running them here is safe.
func TestStepsExecutorRunsMainStepsAfterPreFailure(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
var ran []string
preSteps := []common.Executor{
func(_ context.Context) error {
ran = append(ran, "pre1")
return assert.AnError
},
}
steps := []common.Executor{
func(_ context.Context) error {
ran = append(ran, "always-step")
return nil
},
}
err := newStepsExecutor(rc, preSteps, steps)(context.Background())
require.ErrorIs(t, err, assert.AnError, "the pre-step error is propagated")
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-step failure")
assert.False(t, rc.jobCancelled, "a pre-step failure is not a cancellation")
}
// TestPreStepFailureAffectsMainStepIfStatus verifies the status path used by real
// main-step `if` evaluation. A pre-step failure is not present in StepResults, so
// recording only the context job error is not enough: getJobContext must also report
// failure so success()-default main steps skip and failure() steps run.
func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
ctx := common.WithJobErrorContainer(context.Background())
reportStepError(ctx, rc, assert.AnError)
assert.Equal(t, "failure", rc.getJobContext().Status)
require.ErrorIs(t, common.JobError(ctx), assert.AnError)
defaultStep := &stepRun{
RunContext: rc,
Step: &model.Step{ID: "default-step"},
env: map[string]string{},
}
defaultEnabled, err := isStepEnabled(ctx, defaultStep.getIfExpression(ctx, stepStageMain), defaultStep, stepStageMain)
require.NoError(t, err)
assert.False(t, defaultEnabled, "default success() main step must skip after a pre-step failure")
failureStep := &stepRun{
RunContext: rc,
Step: &model.Step{
ID: "failure-step",
If: yaml.Node{Value: "failure()"},
},
env: map[string]string{},
}
failureEnabled, err := isStepEnabled(ctx, failureStep.getIfExpression(ctx, stepStageMain), failureStep, stepStageMain)
require.NoError(t, err)
assert.True(t, failureEnabled, "failure() main step must run after a pre-step failure")
}
// TestPostStepsContextCancelledIsUsableForFailingStep guards against a panic: post/cleanup
// steps run on a context derived from the cancelled job context, and a failing post step
// records its error via common.SetJobError. If that derived context lacks a job-error container,
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
// from cancellation (so the steps run) yet still carry a usable error container.
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
cancel()
require.ErrorIs(t, cancelled.Err(), context.Canceled)
postCtx, done := postStepsContext(cancelled)
defer done()
// Detached from cancellation, so the post steps actually run.
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
// A failing post step records its error instead of panicking.
require.NotPanics(t, func() {
common.SetJobError(postCtx, assert.AnError)
}, "a failing post step must not panic on the cancel path")
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError)
}
// TestPostStepsContextDeadlinePreservesJobError verifies the job-timeout path keeps the original
// job-error container (via context.WithoutCancel), so the timeout failure and any post-step error
// survive into the post phase and the job is still reported as failed.
func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
base := common.WithJobErrorContainer(context.Background())
common.SetJobError(base, assert.AnError)
expired, cancel := context.WithDeadline(base, time.Now().Add(-time.Hour))
defer cancel()
require.ErrorIs(t, expired.Err(), context.DeadlineExceeded)
postCtx, done := postStepsContext(expired)
defer done()
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

@@ -48,8 +48,11 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
if resumeCommand != "" && command != resumeCommand {
// There should not be any emojis in the log output for Gitea.
// The code in the switch statement is the same.
// Return true (not false) so the line still reaches the raw_output
// log handler; otherwise everything between ::stop-commands:: and
// its end token is silently dropped from the step log.
logger.Infof("%s", line)
return false
return true
}
arg = UnescapeCommandData(arg)
kvPairs = unescapeKvPairs(kvPairs)
@@ -151,30 +154,25 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
return rtn
}
// A Replacer never rescans what it wrote, so "%250A" stays a literal "%0A".
var (
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
)
// escapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
// so the log renderer decodes it back. Lines forwarded from step output are already escaped.
func escapeCommandData(arg string) string {
return commandDataEscaper.Replace(arg)
}
func UnescapeCommandData(arg string) string {
escapeMap := map[string]string{
"%25": "%",
"%0D": "\r",
"%0A": "\n",
}
for k, v := range escapeMap {
arg = strings.ReplaceAll(arg, k, v)
}
return arg
return commandDataUnescaper.Replace(arg)
}
func unescapeCommandProperty(arg string) string {
escapeMap := map[string]string{
"%25": "%",
"%0D": "\r",
"%0A": "\n",
"%3A": ":",
"%2C": ",",
}
for k, v := range escapeMap {
arg = strings.ReplaceAll(arg, k, v)
}
return arg
return commandPropertyUnescaper.Replace(arg)
}
func unescapeKvPairs(kvPairs map[string]string) map[string]string {

View File

@@ -28,6 +28,29 @@ func TestSetEnv(t *testing.T) {
a.Equal("valz", rc.Env["x"])
}
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
a := assert.New(t)
ctx := context.Background()
rc := new(RunContext)
handler := rc.commandHandler(ctx)
// Stop command processing until the matching end token is seen.
a.True(handler("::stop-commands::my-end-token\n"))
// A command-shaped line while stopped must not be executed (env unchanged),
// but must still return true so it reaches the raw_output log handler and is
// not dropped from the step log.
a.True(handler("::set-env name=x::valz\n"))
a.NotContains(rc.Env, "x")
// The matching end token resumes command processing.
a.True(handler("::my-end-token::\n"))
// Commands are processed again after resuming.
a.True(handler("::set-env name=y::valy\n"))
a.Equal("valy", rc.Env["y"])
}
func TestSetOutput(t *testing.T) {
a := assert.New(t)
ctx := context.Background()
@@ -191,3 +214,10 @@ func TestSaveState(t *testing.T) {
assert.Equal(t, "state-value", rc.IntraActionState["step"]["state-name"])
}
func TestEscapeCommandData(t *testing.T) {
a := assert.New(t)
a.Equal("a%25b%0Dc%0Ad%250A", escapeCommandData("a%b\rc\nd%0A"))
a.Equal("a%b\rc\nd%0A", UnescapeCommandData("a%25b%0Dc%0Ad%250A"))
}

View File

@@ -56,7 +56,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
for _, needs := range jobNeeds {
using[needs] = exprparser.Needs{
Outputs: jobs[needs].Outputs,
Result: jobs[needs].Result,
Result: jobs[needs].NeedsResult(),
}
}
@@ -95,9 +95,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
Inputs: inputs,
HashFiles: getHashFilesFunction(ctx, rc),
}
if rc.JobContainer != nil {
ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
}
ee.Runner = rc.getRunnerContext(ctx)
return expressionEvaluator{
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
Run: rc.Run,
@@ -127,7 +125,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step)
for _, needs := range jobNeeds {
using[needs] = exprparser.Needs{
Outputs: jobs[needs].Outputs,
Result: jobs[needs].Result,
Result: jobs[needs].NeedsResult(),
}
}
@@ -149,9 +147,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step)
Inputs: inputs,
HashFiles: getHashFilesFunction(ctx, rc),
}
if rc.JobContainer != nil {
ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
}
ee.Runner = rc.getRunnerContext(ctx)
return expressionEvaluator{
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
Run: rc.Run,
@@ -229,7 +225,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 +494,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 +507,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 +550,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"
@@ -22,6 +23,7 @@ import (
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
)
@@ -55,16 +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.
func reportStepError(ctx context.Context, err error) {
common.Logger(ctx).Errorf("##[error]%v", err)
// 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]%s", escapeCommandData(err.Error()))
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 {
@@ -111,15 +178,19 @@ 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 {
reportStepError(ctx, preErr)
reportStepError(ctx, rc, preErr)
} else if ctx.Err() != nil {
reportStepError(ctx, ctx.Err())
reportStepError(ctx, rc, ctx.Err())
}
return preErr
}))
@@ -129,9 +200,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
rc.CurrentStepIndex = stepIdx
err := stepExec(ctx)
if err != nil {
reportStepError(ctx, err)
reportStepError(ctx, rc, err)
} else if ctx.Err() != nil {
reportStepError(ctx, ctx.Err())
reportStepError(ctx, rc, ctx.Err())
}
return nil
}))
@@ -141,9 +212,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
rc.CurrentStepIndex = stepIdx
err := postFn(ctx)
if err != nil {
reportStepError(ctx, err)
reportStepError(ctx, rc, err)
} else if ctx.Err() != nil {
reportStepError(ctx, ctx.Err())
reportStepError(ctx, rc, ctx.Err())
}
return err
})
@@ -155,10 +226,20 @@ 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
if rc.Config.AutoRemove || jobError == nil {
// jobError == nil keeps a failed job's container alive for post-mortem debugging when
// AutoRemove is off (the act-CLI --rm behavior; the shipped runner always sets
// AutoRemove). A cancelled run is not a failure to inspect, and the cancel-path post
// context now carries its own error container so a failing post step makes jobError
// non-nil — OR in rc.jobCancelled so cancellation still always tears the container down.
if rc.Config.AutoRemove || jobError == nil || rc.jobCancelled {
// always allow 1 min for stopping and removing the runner, even if we were cancelled
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
defer cancel()
@@ -197,25 +278,107 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
return err
})
pipeline := make([]common.Executor, 0)
pipeline = append(pipeline, preSteps...)
pipeline = append(pipeline, steps...)
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
Finally(func(ctx context.Context) error {
var cancel context.CancelFunc
if ctx.Err() == context.Canceled {
// in case of an aborted run, we still should execute the
// post steps to allow cleanup.
ctx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), 5*time.Minute)
defer cancel()
}
return postExecutor(ctx)
// Record an interrupt (backstop for interrupts that land outside the main
// step loop) so the post steps observe the cancelled/failed job status.
rc.markInterrupted(ctx.Err())
postCtx, cancel := postStepsContext(ctx)
defer cancel()
return postExecutor(postCtx)
}).
Finally(info.interpolateOutputs()).
Finally(info.closeContainer()))
}
// postStepsContext derives the context used to run the job's post/cleanup steps from the
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
// returned context always carries a fresh bounded deadline and is never itself cancelled.
//
// - context.Canceled (server cancel): detach from the cancelled context via a fresh root so
// the post steps can run.
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
// keeps the original values — including the job-error container — so the timeout failure and
// any post-step error are preserved and the job is still reported as failed.
// - otherwise: run on the live context unchanged.
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
switch ctx.Err() {
case context.Canceled:
// The cancelled context is abandoned for a fresh root, which drops the job-error
// container installed at the job root. Re-attach a fresh one so a failing post step
// records its error via SetJobError instead of panicking on a nil container.
return context.WithTimeout(common.WithJobErrorContainer(common.WithLogger(context.Background(), common.Logger(ctx))), 5*time.Minute)
case context.DeadlineExceeded:
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
default:
return ctx, func() {}
}
}
// newStepsExecutor sequences the job's pre steps and main steps.
//
// The pre steps run as a normal pipeline that short-circuits on the first failure or
// cancellation. The main-steps executor then runs unconditionally — even if a pre step failed
// or the job was interrupted — so always()/cancelled()/failure() main steps still run, mirroring
// GitHub Actions. This is safe because each main step re-evaluates its own `if` (a pre-step
// failure flips the expression job status to failure, so success()-default steps skip) and
// newMainStepsExecutor detaches from an interrupted context before running the remaining steps.
//
// A pre-step failure or interrupt is still propagated so the job is reported with the correct
// conclusion; the pre error takes precedence since it happened first.
func newStepsExecutor(rc *RunContext, preSteps, steps []common.Executor) common.Executor {
preExecutor := common.NewPipelineExecutor(preSteps...)
mainExecutor := newMainStepsExecutor(rc, steps)
return func(ctx context.Context) error {
preErr := preExecutor(ctx)
mainErr := mainExecutor(ctx)
if preErr != nil {
return preErr
}
return mainErr
}
}
// newMainStepsExecutor runs the job's main-stage step executors in order. Unlike a plain
// pipeline, an interruption (context.Canceled from a server cancel, or context.DeadlineExceeded
// from the job timeout) does not abandon the remaining steps: it marks the job cancelled when
// appropriate and keeps iterating under a fresh, bounded context so steps whose `if` still
// evaluates true — always() and cancelled() — run for cleanup, mirroring GitHub Actions. Steps
// that default to success() skip themselves because success() is false once the job is no longer
// successful. The main-step wrappers report their own errors and return nil, so the loop drives
// step ordering off the context, not return values.
func newMainStepsExecutor(rc *RunContext, steps []common.Executor) common.Executor {
return func(ctx context.Context) error {
for i, step := range steps {
if ctx.Err() != nil {
return runMainStepsAfterInterrupt(ctx, rc, steps[i:])
}
_ = step(ctx)
}
// An interrupt can land during the final step, after the loop's last context
// check; record it so the post steps still observe the cancelled/failed status.
rc.markInterrupted(ctx.Err())
return nil
}
}
// runMainStepsAfterInterrupt runs the remaining main steps after the job context was cancelled or
// timed out. It detaches from the interrupted context (keeping its values: logger and job error)
// and applies a fresh deadline so always()/cancelled() steps run to completion. The original
// interrupt error is returned so callers up the chain still see the job as cancelled/timed out.
func runMainStepsAfterInterrupt(ctx context.Context, rc *RunContext, steps []common.Executor) error {
interruptErr := ctx.Err()
rc.markInterrupted(interruptErr)
freshCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
defer cancel()
for _, step := range steps {
_ = step(freshCtx)
}
return interruptErr
}
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
logger := common.Logger(ctx)
@@ -223,6 +386,12 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
// read-modify-write of the job result so a failing combination is not lost-updated by a
// concurrent succeeding one.
job := rc.Run.Job()
var continueOnError bool
if !success {
// Use a fresh context so an expired job timeout cannot block expression evaluation.
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
}
jobResult := func() string {
defer lockJob(job)()
result := "success"
@@ -233,6 +402,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
}
if !success {
result = "failure"
job.SetContinueOnError(continueOnError)
}
info.result(result)
return result
@@ -271,6 +441,32 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
}
}
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
// step-level evaluateStepTimeout in step.go.
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
if timeout != "" {
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
}
}
return ctx, func() {}
}
// evaluateJobContinueOnError evaluates the job-level continue-on-error expression.
func evaluateJobContinueOnError(ctx context.Context, rc *RunContext, job *model.Job) bool {
expr := strings.TrimSpace(job.RawContinueOnError)
if expr == "" {
return false
}
continueOnError, err := EvalBool(ctx, rc.NewExpressionEvaluator(ctx), expr, exprparser.DefaultStatusCheckNone)
if err != nil {
common.Logger(ctx).Warnf("continue-on-error expression %q evaluation failed: %v", expr, err)
return false
}
return continueOnError
}
func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
if rc == nil || rc.JobContainer == nil || rc.Config == nil {
return
@@ -462,6 +658,11 @@ func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, execu
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
// Flush any buffered, not-yet-newline-terminated trailing line once the
// step has finished, so the final line of the step's output is not lost
// when it is not newline-terminated.
defer common.FlushWriter(logWriter)
return executor(ctx)
}
}

View File

@@ -24,10 +24,12 @@ 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"
"github.com/stretchr/testify/require"
yaml "go.yaml.in/yaml/v4"
)
func TestJobExecutor(t *testing.T) {
@@ -110,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
@@ -347,6 +525,133 @@ func TestNewJobExecutor(t *testing.T) {
}
}
// TestNewJobExecutorRunsPostStepsAfterTimeout guards the timeout-minutes cleanup
// path: when a job exceeds its timeout the job context is DeadlineExceeded, but
// the post steps (cleanup hooks like actions/checkout post and cache save) must
// still run against a fresh, non-expired context, and the job must still be
// reported as failed.
func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
ctx := common.WithJobErrorContainer(context.Background())
// The timeout is generous so the main step (which blocks on ctx.Done below) is
// always reached before the deadline fires; otherwise the pipeline would
// short-circuit before the step runs and the job error would never be set.
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
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)
stepModel := &model.Step{ID: "1"}
jim.On("steps").Return([]*model.Step{stepModel})
jim.On("matrix").Return(map[string]any{})
jim.On("startContainer").Return(func(ctx context.Context) error { return nil })
jim.On("interpolateOutputs").Return(func(ctx context.Context) error { return nil })
jim.On("closeContainer").Return(func(ctx context.Context) error { return nil })
// The job timed out, so it must be reported as failed. stopContainer is left
// unexpected on purpose: a timed-out (failed) job preserves its error state, so
// the graceful stop is skipped exactly like any other failure without AutoRemove.
jim.On("result", "failure")
sm := &stepMock{}
sfm.On("newStep", stepModel, rc).Return(sm, nil)
sm.On("pre").Return(func(ctx context.Context) error { return nil })
// The main step runs past the job timeout: it blocks until the job context is
// done, mirroring a step that overruns timeout-minutes.
sm.On("main").Return(func(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
})
var postRan bool
var postCtxErr error
sm.On("post").Return(func(ctx context.Context) error {
postRan = true
postCtxErr = ctx.Err()
return nil
})
executor := newJobExecutor(jim, sfm, rc)
// The executor itself returns nil on timeout: the failure is surfaced through
// the job result ("failure", asserted via the result mock below), not the
// return value.
require.NoError(t, executor(ctx))
assert.True(t, postRan, "post step must run after a job timeout")
require.NoError(t, postCtxErr, "post step must run against a fresh, non-expired context")
jim.AssertExpectations(t)
sfm.AssertExpectations(t)
sm.AssertExpectations(t)
}
// TestSetJobResultMatrixContinueOnError exercises the parallel-matrix path
// end-to-end: two combinations share one *model.Job and continue-on-error is
// keyed on matrix.experimental, so one combination tolerates its failure and the
// other does not. The job is reported as continue-on-error only when EVERY failing
// combination was tolerated; a single firm failure makes the whole job firm, and
// handleFailure then fails the run.
func TestSetJobResultMatrixContinueOnError(t *testing.T) {
const jobYAML = "continue-on-error: ${{ matrix.experimental }}\nruns-on: ubuntu-latest"
newSharedJob := func(t *testing.T) (*model.Job, *model.Workflow) {
t.Helper()
var job *model.Job
require.NoError(t, yaml.Unmarshal([]byte(jobYAML), &job))
return job, &model.Workflow{
Name: "workflow1",
Jobs: map[string]*model.Job{"job1": job},
}
}
planFor := func(wf *model.Workflow) *model.Plan {
return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: wf, JobID: "job1"}}}}}
}
ctx := context.Background()
// fail drives a single matrix combination through the failure path; each
// RunContext is its own jobInfo (rc implements jobInfo) and shares the job.
fail := func(wf *model.Workflow, experimental bool) {
rc := newTestRC(wf, map[string]any{"experimental": experimental})
setJobResult(ctx, rc, rc, false)
}
t.Run("one tolerated and one firm failure fails the run", func(t *testing.T) {
job, wf := newSharedJob(t)
// Order is intentional: the tolerated combination finishes first, then the
// firm one. The firm-failure latch must still win regardless of order.
fail(wf, true)
fail(wf, false)
assert.Equal(t, "failure", job.Result)
assert.False(t, job.ContinueOnError, "a single firm failure must make the whole job firm")
assert.Error(t, handleFailure(planFor(wf))(ctx))
})
t.Run("all tolerated failures do not fail the run", func(t *testing.T) {
job, wf := newSharedJob(t)
fail(wf, true)
fail(wf, true)
assert.Equal(t, "failure", job.Result)
assert.True(t, job.ContinueOnError, "every failing combination was tolerated")
assert.NoError(t, handleFailure(planFor(wf))(ctx))
})
}
func TestHasJobSummaryCapability(t *testing.T) {
assert.True(t, hasJobSummaryCapability("cache,job-summary artifacts"))
assert.True(t, hasJobSummaryCapability("cache,\njob-summary\tartifacts"))
@@ -674,3 +979,104 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
require.NoError(t, tw.Close())
return buf.Bytes()
}
func newTestRC(wf *model.Workflow, matrix map[string]any) *RunContext {
return &RunContext{
Config: &Config{
Workdir: ".",
Platforms: map[string]string{
"ubuntu-latest": "ubuntu-latest",
},
},
StepResults: map[string]*model.StepResult{},
Env: map[string]string{},
Matrix: matrix,
Run: &model.Run{JobID: "job1", Workflow: wf},
}
}
func makeTestRC(t *testing.T, jobYAML string) *RunContext {
t.Helper()
var job *model.Job
require.NoError(t, yaml.Unmarshal([]byte(jobYAML), &job))
rc := newTestRC(&model.Workflow{
Name: "workflow1",
Jobs: map[string]*model.Job{"job1": job},
}, nil)
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
return rc
}
func TestApplyJobTimeout(t *testing.T) {
cases := []struct {
name string
yaml string
wantTimeout bool
}{
{"empty", "runs-on: ubuntu-latest", false},
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true},
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rc := makeTestRC(t, tc.yaml)
ctx := context.Background()
newCtx, cancel := applyJobTimeout(ctx, rc, rc.Run.Job())
defer cancel()
_, hasDeadline := newCtx.Deadline()
assert.Equal(t, tc.wantTimeout, hasDeadline)
})
}
}
func TestEvaluateJobContinueOnError(t *testing.T) {
cases := []struct {
name string
yaml string
want bool
}{
{"absent", "runs-on: ubuntu-latest", false},
{"true", "continue-on-error: true\nruns-on: ubuntu-latest", true},
{"false", "continue-on-error: false\nruns-on: ubuntu-latest", false},
{"expression true", "continue-on-error: ${{ 'x' == 'x' }}\nruns-on: ubuntu-latest", true},
{"expression false", "continue-on-error: ${{ 'x' != 'x' }}\nruns-on: ubuntu-latest", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rc := makeTestRC(t, tc.yaml)
got := evaluateJobContinueOnError(context.Background(), rc, rc.Run.Job())
assert.Equal(t, tc.want, got)
})
}
}
func TestJobSetContinueOnError(t *testing.T) {
t.Run("first call true", func(t *testing.T) {
j := &model.Job{}
j.SetContinueOnError(true)
assert.True(t, j.ContinueOnError)
})
t.Run("first call false", func(t *testing.T) {
j := &model.Job{}
j.SetContinueOnError(false)
assert.False(t, j.ContinueOnError)
})
t.Run("true then false locks to false", func(t *testing.T) {
j := &model.Job{}
j.SetContinueOnError(true)
j.SetContinueOnError(false)
assert.False(t, j.ContinueOnError)
})
t.Run("false then true stays false", func(t *testing.T) {
j := &model.Job{}
j.SetContinueOnError(false)
j.SetContinueOnError(true)
assert.False(t, j.ContinueOnError)
})
t.Run("true then true stays true", func(t *testing.T) {
j := &model.Job{}
j.SetContinueOnError(true)
j.SetContinueOnError(true)
assert.True(t, j.ContinueOnError)
})
}

View File

@@ -175,6 +175,10 @@ func AppendSecretMasker(oldnew []string, v string) []string {
// formatted JSON secrets could otherwise mask {,[,],} everywhere
if len(tm) > 1 {
ret = append(ret, tm, "***")
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
if strings.ContainsAny(tm, "%\r\n") {
ret = append(ret, escapeCommandData(tm), "***")
}
}
}
@@ -230,6 +234,11 @@ type jobLogFormatter struct {
func (f *jobLogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
b := &bytes.Buffer{}
// the web renderer decodes command data, so this local view has to as well
if _, _, _, ok := tryParseRawActionCommand(entry.Message + "\n"); ok {
entry.Message = UnescapeCommandData(entry.Message)
}
if f.isColored(entry) {
f.printColored(b, entry)
} else {

View File

@@ -4,11 +4,13 @@
package runner
import (
"io"
"strings"
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValueMasker(t *testing.T) {
@@ -33,6 +35,12 @@ func TestValueMasker(t *testing.T) {
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
},
{
name: "Secret containing a percent sign",
lines: "##[error]login failed for pass%25word",
secrets: map[string]string{"TOKEN": "pass%word"},
disallowed: []string{"pass%25word"},
},
}
for _, entry := range table {
t.Run(entry.name, func(t *testing.T) {
@@ -50,3 +58,17 @@ func TestValueMasker(t *testing.T) {
})
}
}
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
logger := logrus.New()
logger.Out = io.Discard
format := func(message string) string {
out, err := (&jobLogFormatter{}).Format(&logrus.Entry{Logger: logger, Message: message, Data: logrus.Fields{rawOutputField: true}})
require.NoError(t, err)
return string(out)
}
assert.Contains(t, format("##[error]deploy 50%25 traffic"), "##[error]deploy 50% traffic")
// a plain line is not command data and keeps its literal escapes
assert.Contains(t, format("progress 50%25 done"), "progress 50%25 done")
}

View File

@@ -141,6 +141,7 @@ func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory,
Dir: targetDirectory,
Token: token,
OfflineMode: rc.Config.ActionOfflineMode,
Depth: rc.Config.ActionCloneDepth,
})(ctx)
}
}
@@ -304,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

@@ -73,6 +73,39 @@ type RunContext struct {
// captured before execution so each matrix combo interpolates from the originals rather
// than from a sibling's already-resolved values written into the shared Job.Outputs.
outputTemplate map[string]string
// jobCancelled records that this job's run was cancelled (context.Canceled). It makes
// getJobContext report the "cancelled" status so cancelled()/always() evaluate the way
// GitHub Actions does, letting cleanup and always() steps run while normal steps skip.
jobCancelled bool
// jobFailed records failures outside normal main-step results, such as action pre-step
// failures. Those failures must still make success() false and failure() true for later
// main-step if evaluation.
jobFailed bool
}
// markCancelled flags the job as cancelled so subsequent step `if` evaluations and the
// job status context observe the "cancelled" state.
func (rc *RunContext) markCancelled() {
rc.jobCancelled = true
}
// markFailed flags the job as failed so subsequent step `if` evaluations observe
// failure even when the error happened outside a main step result.
func (rc *RunContext) markFailed() {
rc.jobFailed = true
}
// markInterrupted records the job's interruption status from a context error so later step `if` evaluations and the job result observe it,
// keeping the timeout path symmetric with the cancel path:
// - context.Canceled (server cancel) marks the job cancelled, matching GitHub's "only always()/cancelled() run on cancel".
// - context.DeadlineExceeded (job timeout-minutes) marks the job failed, matching the "Timeout -> FAILURE" reporting semantics.
func (rc *RunContext) markInterrupted(err error) {
switch {
case errors.Is(err, context.Canceled):
rc.markCancelled()
case errors.Is(err, context.DeadlineExceeded):
rc.markFailed()
}
}
func (rc *RunContext) AddMask(mask string) {
@@ -105,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"
@@ -189,6 +224,9 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
if job := rc.Run.Job(); job != nil {
if container := job.Container(); container != nil {
for _, v := range container.Volumes {
if rc.ExprEval != nil {
v = rc.ExprEval.Interpolate(context.Background(), v)
}
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
// Bind anonymous volume or host file.
binds = append(binds, v)
@@ -260,7 +298,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
AllocatePTY: rc.Config.AllocatePTY,
}
rc.cleanUpJobContainer = rc.JobContainer.Remove()
for k, v := range rc.JobContainer.GetRunnerContext(ctx) {
for k, v := range rc.getRunnerContext(ctx) {
if v, ok := v.(string); ok {
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
}
@@ -301,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)
@@ -343,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 {
@@ -357,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)
}
@@ -378,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,
@@ -404,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),
@@ -471,7 +491,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
rc.stopJobContainer(),
container.NewDockerNetworkCreateExecutor(networkName).IfBool(createAndDeleteNetwork),
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
IfBool(createAndDeleteNetwork),
rc.startServiceContainers(networkName),
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
rc.JobContainer.Start(false),
@@ -488,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)
@@ -746,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 {
@@ -900,12 +962,21 @@ func trimToLen(s string, l int) string {
func (rc *RunContext) getJobContext() *model.JobContext {
jobStatus := "success"
if rc.jobFailed {
jobStatus = "failure"
}
for _, stepStatus := range rc.StepResults {
if stepStatus.Conclusion == model.StepStatusFailure {
jobStatus = "failure"
break
}
}
// A cancelled run takes precedence over success/failure so cancelled() is true and
// success()/failure() are false, matching GitHub Actions: on cancellation only
// always() and cancelled() steps run.
if rc.jobCancelled {
jobStatus = "cancelled"
}
return &model.JobContext{
Status: jobStatus,
}
@@ -915,6 +986,21 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
return rc.StepResults
}
// getRunnerContext returns the `runner` context: what the execution environment knows
// (os, arch, temp, tool_cache) plus what only the runner process knows.
func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
runnerContext := map[string]any{}
if rc.JobContainer != nil {
maps0.Copy(runnerContext, rc.JobContainer.GetRunnerContext(ctx))
}
runnerContext["name"] = rc.Config.RunnerName
runnerContext["environment"] = "self-hosted"
if rc.Config.RunnerDebug() {
runnerContext["debug"] = "1"
}
return runnerContext
}
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
logger := common.Logger(ctx)
ghc := &model.GithubContext{
@@ -1099,7 +1185,7 @@ func nestedMapLookup(m map[string]any, ks ...string) (rval any) {
}
}
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) map[string]string { //nolint:unparam // pre-existing issue from nektos/act
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
env["CI"] = "true"
env["GITHUB_WORKFLOW"] = github.Workflow
env["GITHUB_RUN_ID"] = github.RunID
@@ -1141,23 +1227,71 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
}
env["RUNNER_NAME"] = rc.Config.RunnerName
env["RUNNER_ENVIRONMENT"] = "self-hosted"
if workspace := parentDir(github.Workspace); workspace != "" {
env["RUNNER_WORKSPACE"] = workspace
}
if rc.Config.RunnerDebug() {
env["RUNNER_DEBUG"] = "1"
}
if rc.Config.ArtifactServerPath != "" {
setActionRuntimeVars(rc, env)
}
for _, platformName := range rc.runsOnPlatformNames(ctx) {
if platformName != "" {
if platformName == "ubuntu-latest" {
// hardcode current ubuntu-latest since we have no way to check that 'on the fly'
env["ImageOS"] = "ubuntu20"
} else {
platformName = strings.SplitN(strings.Replace(platformName, `-`, ``, 1), `.`, 2)[0]
env["ImageOS"] = platformName
}
}
if imageOS := rc.imageOS(ctx); imageOS != "" {
env["ImageOS"] = imageOS
}
}
// parentDir returns the directory containing p, or "" when p names no parent. Both
// separators are accepted rather than filepath's, as p may describe a container while
// the runner itself runs on Windows, or the other way round.
func parentDir(p string) string {
if slash := strings.LastIndexAny(p, `/\`); slash > 0 {
return p[:slash]
}
return ""
}
// imageOS returns ImageOS, which setup-* actions use to tell one runner image release
// from another. The resolved image tag is preferred over the runs-on label because it
// still names a release when the label is a rolling one such as ubuntu-latest.
func (rc *RunContext) imageOS(ctx context.Context) string {
if rc.Run.Job().RunsOn() == nil {
// A composite action runs on a synthetic job, and resolving its image would only
// log that runs-on is missing.
return ""
}
if imageOS := imageOSFromImage(rc.platformImage(ctx)); imageOS != "" {
return imageOS
}
return env
for _, platformName := range slices.Backward(rc.runsOnPlatformNames(ctx)) {
if platformName == "ubuntu-latest" {
// Rolling label whose image names no release either, so keep the historical value.
return "ubuntu20"
} else if platformName != "" {
return strings.SplitN(strings.Replace(platformName, `-`, ``, 1), `.`, 2)[0]
}
}
return ""
}
// imageOSTag matches an image reference tagged with an OS family ImageOS can report plus
// its release, such as "docker.gitea.com/runner-images:ubuntu-24.04". Anything else
// ("ubuntu-latest", "app:22.04", "catthehacker/ubuntu:act-22.04", or a registry port) is
// left to the runs-on label rather than turned into a bogus OS.
var imageOSTag = regexp.MustCompile(`:(ubuntu|win|macos)-?([0-9]+)[^/]*$`)
// imageOSFromImage derives ImageOS from an image reference, e.g.
// "docker.gitea.com/runner-images:ubuntu-24.04" yields "ubuntu24".
func imageOSFromImage(image string) string {
if match := imageOSTag.FindStringSubmatch(image); match != nil {
return match[1] + match[2]
}
return ""
}
func setActionRuntimeVars(rc *RunContext, env map[string]string) {

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",
@@ -276,6 +365,37 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
}
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
job := &model.Job{}
err := job.RawContainer.Encode(map[string][]string{
"volumes": {"${{ secrets.MAME }}:/root/.mame/roms:ro"},
})
require.NoError(t, err)
rc := &RunContext{
Name: "TestRCName",
Run: &model.Run{
Workflow: &model.Workflow{
Name: "TestWorkflowName",
},
},
Config: &Config{
BindWorkdir: false,
Secrets: map[string]string{
"MAME": "/host/mame/roms",
},
},
}
rc.Run.JobID = "job1"
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
gotbind, gotmount := rc.GetBindsAndMounts()
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
})
for _, testcase := range tests {
t.Run(testcase.name, func(t *testing.T) {
job := &model.Job{}
@@ -332,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.
@@ -780,3 +940,117 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) })
})
}
func TestImageOSFromImage(t *testing.T) {
for _, tc := range []struct {
image string
want string
}{
{"", ""},
{"docker.gitea.com/runner-images:ubuntu-24.04", "ubuntu24"},
{"docker.gitea.com/runner-images:ubuntu-latest", ""},
{"runner-images:ubuntu22.04", "ubuntu22"},
{"node:20", ""},
{"ubuntu:22.04", ""},
{"ubuntu", ""},
{"catthehacker/ubuntu:act-22.04", ""},
{"myco/ubuntu:v2.1", ""},
{"myco/ubuntu:v22.04", ""},
{"app:release-1", ""},
{"app:1.2.3", ""},
{"app:build-2.1", ""},
{"registry.example.com:5000/runner-images", ""},
{"registry.example.com:5000/runner-images:ubuntu-24.04", "ubuntu24"},
} {
t.Run(tc.image, func(t *testing.T) {
assert.Equal(t, tc.want, imageOSFromImage(tc.image))
})
}
}
func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext {
return createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, "runs-on: "+runsOn, ""),
})
}
func TestRunContextImageOS(t *testing.T) {
ctx := context.Background()
t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.Platforms = map[string]string{
"ubuntu-latest": "docker.gitea.com/runner-images:ubuntu-24.04",
}
assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
})
t.Run("falls back to the runs-on label", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-22.04")
rc.Config.Platforms = map[string]string{"ubuntu-22.04": "some-image"}
assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
})
t.Run("keeps the historical value for a rolling label with no release", func(t *testing.T) {
assert.Equal(t, "ubuntu20", createRunsOnRunContext(t, "ubuntu-latest").imageOS(ctx))
})
t.Run("is empty for the synthetic job of a composite action", func(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{"job1": {}})
assert.Empty(t, rc.imageOS(ctx))
})
}
func TestRunContextGetRunnerContext(t *testing.T) {
ctx := context.Background()
t.Run("adds the runner values the container cannot know", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.RunnerName = "runner-1"
runnerContext := rc.getRunnerContext(ctx)
assert.Equal(t, "runner-1", runnerContext["name"])
assert.Equal(t, "self-hosted", runnerContext["environment"])
assert.NotContains(t, runnerContext, "debug")
})
t.Run("reports debug when step debugging is on", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
assert.Equal(t, "1", rc.getRunnerContext(ctx)["debug"])
})
t.Run("keeps the execution environment values", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.JobContainer = &container.HostEnvironment{TmpDir: "/tmp/act", ToolCache: "/tmp/tool_cache"}
runnerContext := rc.getRunnerContext(ctx)
assert.Equal(t, "/tmp/act", runnerContext["temp"])
assert.Equal(t, "/tmp/tool_cache", runnerContext["tool_cache"])
assert.NotEmpty(t, runnerContext["os"])
})
}
func TestParentDir(t *testing.T) {
assert.Empty(t, parentDir(""))
assert.Empty(t, parentDir("repo"))
assert.Empty(t, parentDir("/repo"))
assert.Equal(t, "/workspace/owner", parentDir("/workspace/owner/repo"))
assert.Equal(t, `C:\workspace\owner`, parentDir(`C:\workspace\owner\repo`))
}
func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
ctx := context.Background()
rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.RunnerName = "runner-1"
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
env := map[string]string{}
rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env)
assert.Equal(t, "runner-1", env["RUNNER_NAME"])
assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"])
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
assert.Equal(t, "1", env["RUNNER_DEBUG"])
}

View File

@@ -15,6 +15,7 @@ import (
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model"
docker_container "github.com/moby/moby/api/types/container"
@@ -28,60 +29,77 @@ type Runner interface {
// Config contains the config for a new runner
type Config struct {
Actor string // the user that triggered the event
Workdir string // path to working directory
ActionCacheDir string // path used for caching action contents
ActionOfflineMode bool // when offline, use cached action contents
BindWorkdir bool // bind the workdir to the job container
EventName string // name of event to run
EventPath string // path to JSON file to use for event.json in containers
DefaultBranch string // name of the main branch for this repository
ReuseContainers bool // reuse containers to maintain state
ForcePull bool // force pulling of the image, even if already present
ForceRebuild bool // force rebuilding local docker image action
LogOutput bool // log the output from docker run
JSONLogger bool // use json or text logger
LogPrefixJobID bool // switches from the full job name to the job id
Env map[string]string // env for containers
Inputs map[string]string // manually passed action inputs
Secrets map[string]string // list of secrets
Vars map[string]string // list of vars
Token string // GitHub token
InsecureSecrets bool // switch hiding output when printing to terminal
Platforms map[string]string // list of platforms
Privileged bool // use privileged mode
UsernsMode string // user namespace to use
ContainerArchitecture string // Desired OS/architecture platform for running containers
ContainerDaemonSocket string // Path to Docker daemon socket
ContainerOptions string // Options for the job container
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
GitHubInstance string // GitHub instance to use, default "github.com"
ContainerCapAdd []string // list of kernel capabilities to add to the containers
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
AutoRemove bool // controls if the container is automatically removed upon workflow completion
ArtifactServerPath string // the path where the artifact server stores uploads
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
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.
Matrix map[string]map[string]bool // Matrix config to run
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
ActionCache ActionCache // Use a custom ActionCache Implementation
Actor string // the user that triggered the event
Workdir string // path to working directory
ActionCacheDir string // path used for caching action contents
ActionOfflineMode bool // when offline, use cached action contents
ActionCloneDepth int // limit history when cloning an action repo; 0 clones every branch in full
BindWorkdir bool // bind the workdir to the job container
EventName string // name of event to run
EventPath string // path to JSON file to use for event.json in containers
DefaultBranch string // name of the main branch for this repository
ReuseContainers bool // reuse containers to maintain state
ForcePull bool // force pulling of the image, even if already present
ForceRebuild bool // force rebuilding local docker image action
LogOutput bool // log the output from docker run
JSONLogger bool // use json or text logger
LogPrefixJobID bool // switches from the full job name to the job id
Env map[string]string // env for containers
Inputs map[string]string // manually passed action inputs
Secrets map[string]string // list of secrets
Vars map[string]string // list of vars
Token string // GitHub token
InsecureSecrets bool // switch hiding output when printing to terminal
Platforms map[string]string // list of platforms
Privileged bool // use privileged mode
UsernsMode string // user namespace to use
ContainerArchitecture string // Desired OS/architecture platform for running containers
ContainerDaemonSocket string // Path to Docker daemon socket
ContainerOptions string // Options for the job container
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
GitHubInstance string // GitHub instance to use, default "github.com"
ContainerCapAdd []string // list of kernel capabilities to add to the containers
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
AutoRemove bool // controls if the container is automatically removed upon workflow completion
ArtifactServerPath string // the path where the artifact server stores uploads
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.
Matrix map[string]map[string]bool // Matrix config to run
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
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
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
}
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
// RUNNER_DEBUG. Only the secret also makes the reporter keep ::debug:: output, the env
// is accepted for `exec` and for runners configured with it.
func (c Config) RunnerDebug() bool {
return c.Secrets["ACTIONS_STEP_DEBUG"] == "true" || c.Env["ACTIONS_STEP_DEBUG"] == "true"
}
// GetToken: Adapt to Gitea
@@ -127,6 +145,11 @@ func New(runnerConfig *Config) (Runner, error) {
}
func (runner *runnerImpl) configure() (Runner, error) {
if runner.config.RunnerName == "" {
// Callers that do not register, such as `exec`, still get a `runner.name`.
runner.config.RunnerName, _ = os.Hostname()
}
runner.eventJSON = "{}"
if runner.config.EventJSON != "" {
runner.eventJSON = runner.config.EventJSON
@@ -248,7 +271,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
return err
}
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
jobCtx := common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix))
jobCtx, cancelTimeout := applyJobTimeout(jobCtx, rc, job)
defer cancelTimeout()
return executor(jobCtx)
})
}
// Run all matrix combinations of this job, then drop its aggregation mutex: the
@@ -303,7 +329,7 @@ func handleFailure(plan *model.Plan) common.Executor {
return func(ctx context.Context) error {
for _, stage := range plan.Stages {
for _, run := range stage.Runs {
if run.Job().Result == "failure" {
if run.Job().Result == "failure" && !run.Job().ContinueOnError {
return fmt.Errorf("Job '%s' failed", run.String())
}
}

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()
@@ -175,7 +181,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
}
if continueOnError {
logger.Errorf("##[error]%v", err)
logger.Errorf("##[error]%s", escapeCommandData(err.Error()))
logger.Infof("Failed but continue next step")
err = nil
stepResult.Conclusion = model.StepStatusSuccess

View File

@@ -114,13 +114,25 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(defaultActionURL))
// For Gitea
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
// empty token and clone the action anonymously (401 against the authenticated
// instance). github.Token survives the composite config copy and matches the
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
token := ""
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
token = github.Token
}
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
URL: sar.remoteAction.CloneURL(defaultActionURL),
URL: cloneURL,
Ref: sar.remoteAction.Ref,
Dir: actionDir,
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
})
@@ -136,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))
@@ -155,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{}
@@ -303,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
@@ -312,7 +350,7 @@ func (ra *remoteAction) IsCheckout() bool {
func newRemoteAction(action string) *remoteAction {
// support http(s)://host/owner/repo@v3
for _, schema := range []string{"https://", "http://"} {
for _, schema := range []string{"https://", "http://", "ssh://"} {
if after, ok := strings.CutPrefix(action, schema); ok {
splits := strings.SplitN(after, "/", 2)
if len(splits) != 2 {

View File

@@ -10,6 +10,9 @@ import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
@@ -778,6 +781,32 @@ func Test_newRemoteAction(t *testing.T) {
},
wantCloneURL: "http://gitea.com/actions/aws",
},
{
action: "ssh://git@gitea.com/actions/heroku@main", // it's invalid for GitHub, but gitea supports it
want: &remoteAction{
URL: "ssh://git@gitea.com",
Org: "actions",
Repo: "heroku",
Path: "",
Ref: "main",
},
wantCloneURL: "ssh://git@gitea.com/actions/heroku",
},
{
action: "ssh://git@gitea.com/actions/aws/ec2@main", // the ssh user is kept as part of the host segment
want: &remoteAction{
URL: "ssh://git@gitea.com",
Org: "actions",
Repo: "aws",
Path: "ec2",
Ref: "main",
},
wantCloneURL: "ssh://git@gitea.com/actions/aws",
},
{
action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
@@ -792,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
@@ -812,3 +932,83 @@ func Test_safeFilename(t *testing.T) {
})
}
}
// Regression: a nested action in a composite cloned anonymously (401) because the
// composite RunContext nils Config.Secrets. The token must come from github.Token,
// which survives the config copy; the host gate must still withhold it cross-host.
func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
const wantToken = "job-token"
table := []struct {
name string
gitHubInstance string
defaultActionInstance string
wantCloneToken string
}{
{
name: "same host forwards token despite nil secrets",
gitHubInstance: "gitea.example.com",
wantCloneToken: wantToken,
},
{
name: "foreign host is not given the token",
gitHubInstance: "gitea.example.com",
defaultActionInstance: "github.com",
wantCloneToken: "",
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
var capturedToken string
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
capturedToken = input.Token
return func(ctx context.Context) error { return nil }
}
defer (func() {
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
})()
sarm := &stepActionRemoteMocks{}
sar := &stepActionRemote{
Step: &model.Step{Uses: "org/repo@v1"},
RunContext: &RunContext{
Config: &Config{
GitHubInstance: tt.gitHubInstance,
DefaultActionInstance: tt.defaultActionInstance,
ActionCacheDir: "/tmp/test-cache",
// Mirrors the state of a composite RunContext: job secrets are
// stripped, but the job token is still reachable via Config.Token.
Secrets: nil,
Token: wantToken,
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{"1": {}},
},
},
StepResults: map[string]*model.StepResult{},
},
readAction: sarm.readAction,
}
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
suffixMatcher := func(suffix string) any {
return mock.MatchedBy(func(actionDir string) bool {
return strings.HasSuffix(actionDir, suffix)
})
}
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
err := sar.prepareActionExecutor()(ctx)
require.NoError(t, err)
assert.Equal(t, tt.wantCloneToken, capturedToken)
sarm.AssertExpectations(t)
})
}
}

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

@@ -63,7 +63,7 @@ func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
rawLogger.Infof("::group::Run %s", sr.runScriptGroupTitle(normalized))
rawLogger.Infof("::group::Run %s", escapeCommandData(sr.runScriptGroupTitle(normalized)))
if normalized != "" {
for line := range strings.SplitSeq(normalized, "\n") {
@@ -90,7 +90,7 @@ func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]
if step.Name != "" {
title = step.Name
}
rawLogger.Infof("::group::Run %s", title)
rawLogger.Infof("::group::Run %s", escapeCommandData(title))
if len(step.With) > 0 {
rawLogger.Infof("with:")

View File

@@ -167,6 +167,9 @@ func TestSetupEnv(t *testing.T) {
delete((env), "GITHUB_REPOSITORY")
delete((env), "GITHUB_REPOSITORY_OWNER")
delete((env), "GITHUB_ACTOR")
// Host-dependent, asserted in TestRunContextWithGithubEnvRunnerValues instead.
delete((env), "RUNNER_NAME")
delete((env), "RUNNER_WORKSPACE")
assert.Equal(t, map[string]string{
"ACT": "true",
@@ -192,6 +195,7 @@ func TestSetupEnv(t *testing.T) {
"GITHUB_WORKFLOW": "",
"INPUT_STEP_WITH": "with-value",
"RC_KEY": "rcvalue",
"RUNNER_ENVIRONMENT": "self-hosted",
"RUNNER_PERFLOG": "/dev/null",
"RUNNER_TRACKING_ID": "",
}, env)

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

155
docs/post-task-script.md Normal file
View File

@@ -0,0 +1,155 @@
# Post-task script
The post-task script is an optional host hook that runs **once after every task**, after the runner has already finished its normal per-task cleanup. Typical uses include pruning Docker images, vacuuming ephemeral disks, or resetting VM state between jobs.
It is configured under `runner.post_task_script` in the runner YAML config (see [config.example.yaml](../internal/pkg/config/config.example.yaml)).
## When it runs
For each task, execution order is:
1. Workflow runs (steps, actions, containers).
2. In-job cleanup (action `post:` steps, container stop/remove).
3. Job outputs are reported to Gitea.
4. Bind-workdir workspace removal, when `container.bind_workdir` is enabled.
5. **Post-task script** (this hook).
6. Final task acknowledgement to Gitea (`reporter.Close()`).
The script is **additive**: it does not replace any built-in cleanup. When `container.bind_workdir` is enabled, the task workspace directory has usually already been deleted before the script starts. `GITEA_WORKSPACE` is still set to the path the job used, for reference.
## Runner stays offline until the script finishes
This is the most important operational detail.
When the post-task script starts, the runner **stops sending task heartbeats** to Gitea (the same mechanism used during cancel/cleanup). From Gitea's perspective, the runner is **not available for new work** until:
1. The script exits (success or failure), **and**
2. The runner sends the final task flush to Gitea.
While the script runs:
- **Gitea will not assign another task** to this runner for the current job slot (heartbeats are stopped).
- **The runner capacity slot stays occupied** locally — with `capacity: 1`, the poller will not start another task until the script completes.
- **Runner shutdown** (`shutdown_timeout`) counts this phase as part of the in-flight task; a long or stuck script delays graceful shutdown.
If the script **never exits**, the runner remains in this state until `runner.post_task_script_timeout` elapses (default **5 minutes** when a script is configured). The runner then kills the script process and proceeds to the final acknowledgement. Until that timeout fires, **the runner effectively stays offline**.
Set `post_task_script_timeout` to a value that matches how long your housekeeping is allowed to take — not how long you wish it could take. Prefer short, bounded scripts.
### Recommendations
- Keep scripts **fast and bounded** (seconds, not minutes).
- Avoid interactive prompts, blocking network calls without timeouts, or waiting on user input.
- Use **idempotent** operations (the script may run after success, failure, or cancellation).
- Test failure modes: hung script, non-zero exit, missing executable.
- Watch the **runner process log** for script output (it is not written to the Gitea job log).
- On shutdown, ensure scripts respond to process termination within `post_task_script_timeout`.
## Configuration
```yaml
runner:
# Path to an executable on the host. Empty or omitted disables the hook.
post_task_script: /usr/local/bin/gitea-post-task.sh
# Hard limit on script runtime. Default when post_task_script is set: 5m.
# If the script exceeds this, it is killed and the runner continues.
post_task_script_timeout: 2m
```
| Option | Default | Description |
| --- | --- | --- |
| `runner.post_task_script` | *(disabled)* | Host path to the script or binary. Relative paths are resolved from the runner process working directory. |
| `runner.post_task_script_timeout` | `5m` (only when script is set) | Maximum time the script may run before the runner kills it and moves on. |
The script must be **executable** on the host (shebang on Linux/macOS, or a native `.exe` / `.bat` / `.cmd` on Windows). **PowerShell (`.ps1`) is not supported yet** as the value of `post_task_script`; the runner executes the configured path directly and does not invoke `powershell.exe` for you.
`gitea-runner exec` does **not** load runner YAML and will not run this hook.
## Environment variables
The script receives `runner.envs` / `runner.env_file` values plus:
| Variable | Description |
| --- | --- |
| `GITEA_TASK_ID` | Numeric task ID. |
| `GITEA_RUN_ID` | Workflow run ID, when provided by the server. |
| `GITEA_REPOSITORY` | Repository slug (`owner/name`). |
| `GITEA_WORKSPACE` | Workspace path used for the job (may already be deleted). |
| `GITEA_JOB_RESULT` | `success`, `failure`, `cancelled`, `skipped`, or `unknown`. |
The script environment is **not** a full copy of the job container environment. System variables such as `PATH` are only present if you define them in `runner.envs` or `runner.env_file`.
## Output and errors
- **Stdout/stderr** are written to the **runner process log** (logrus), prefixed with `post-task script stdout:` / `post-task script stderr:`.
- **Non-zero exit codes** are logged as warnings only. They do **not** change the job result already reported to Gitea.
- **Timeouts and start failures** are logged as warnings; the runner still completes the task acknowledgement.
## Interaction with other timeouts
| Timeout | Effect on post-task script |
| --- | --- |
| `runner.post_task_script_timeout` | Kills the script if it runs too long. This is the **only** timeout that bounds the script. |
| `runner.timeout` | Caps the task **up to** the script. The script detaches from the task deadline, so a job near the runner timeout limit does **not** cut the script short — it still gets its full `post_task_script_timeout`. |
| `runner.shutdown_timeout` | On SIGINT/SIGTERM, bounds how long the runner waits for the **task** to finish. The post-task script detaches from cancellation, so it is **not** interrupted by this window and may extend shutdown until its own `post_task_script_timeout` elapses. |
## Examples
### Linux — prune dangling Docker resources
`/usr/local/bin/gitea-post-task.sh`:
```sh
#!/bin/sh
set -eu
docker image prune -f
docker builder prune -f --filter 'until=24h'
```
`config.yaml`:
```yaml
runner:
post_task_script: /usr/local/bin/gitea-post-task.sh
post_task_script_timeout: 3m
```
### Windows — batch file (`.cmd`)
Use a `.cmd` or `.bat` file. PowerShell scripts are **not supported yet** as `post_task_script`; call PowerShell from a batch wrapper if needed:
`C:\gitea-runner\scripts\post-task.cmd`:
```bat
@echo off
docker image prune -f
```
```yaml
runner:
post_task_script: C:\gitea-runner\scripts\post-task.cmd
post_task_script_timeout: 3m
```
PowerShell workaround until native `.ps1` support exists:
`C:\gitea-runner\scripts\post-task.cmd`:
```bat
@echo off
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0post-task.ps1"
```
## Windows notes
- Supported as `post_task_script`: `.exe`, `.bat`, `.cmd`.
- **Not supported yet:** `.ps1` as the configured path (use a `.cmd` wrapper; see above).
- `.sh` files require a Unix shell on the PATH unless you point `post_task_script` at the interpreter.
- Use backslashes or forward slashes in YAML paths; both work in Go on Windows.
## See also
- [Configuration](../README.md#configuration) — generating and loading `config.yaml`
- [config.example.yaml](../internal/pkg/config/config.example.yaml) — all runner options
- Bind-workdir idle cleanup (`runner.workdir_cleanup_age`) — separate from this hook; runs only when the runner is idle

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

38
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.5.3+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,25 +20,27 @@ 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.54.2
github.com/moby/moby/client v0.4.1
github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.5.0
github.com/moby/patternmatcher v0.6.1
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
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
go.etcd.io/bbolt v1.4.3
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
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
)

78
go.sum
View File

@@ -47,8 +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.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=
@@ -102,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=
@@ -117,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=
@@ -127,10 +127,10 @@ 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/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/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.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=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
@@ -155,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=
@@ -207,41 +207,41 @@ 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=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
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=
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=
@@ -250,16 +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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
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
}

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