Compare commits

..

26 Commits

Author SHA1 Message Date
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
85 changed files with 4576 additions and 481 deletions

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24

View File

@@ -17,7 +17,7 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
@@ -52,7 +52,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # all history for all branches and tags

View File

@@ -9,7 +9,7 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0 # all history for all branches and tags
- uses: actions/setup-go@v6
@@ -55,7 +55,7 @@ jobs:
DOCKER_LATEST: latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # all history for all branches and tags

View File

@@ -17,7 +17,7 @@ jobs:
# to ~/.docker with the stale credentials.
DOCKER_CONFIG: /tmp/docker-noauth
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
@@ -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

@@ -17,14 +17,14 @@ RUN make clean && make build
### DIND VARIANT
#
#
FROM docker:29.5.3-dind AS dind
FROM docker:29.6.1-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.1-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

View File

@@ -160,9 +160,42 @@ Prefer a YAML file for all settings.
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`)
#### Caching (`actions/cache`)
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.
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"
```
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
```
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 +209,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,10 @@ 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
// For Gitea
InsecureSkipTLS bool
}
@@ -309,7 +313,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
@@ -364,6 +368,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 +445,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 +484,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,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
@@ -49,6 +50,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 +103,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 +404,96 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
})
}
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

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

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

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

@@ -23,6 +23,7 @@ import (
"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"
@@ -261,7 +262,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 +322,14 @@ 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)
// A step often launches a process tree (a shell that starts a child which
// spawns further background or GUI 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 on
// cancellation — via a Job Object on Windows and the process group on Unix
// (see processKiller) — and bound the wait so a leftover pipe writer can
// never hang Wait indefinitely.
var killer atomic.Pointer[processKiller]
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). This
// also covers a step that backgrounds a process holding the pipe open.
cmd.WaitDelay = 10 * time.Second
// Kill the step's whole process tree on cancellation (a step often launches a
// shell that spawns further background or GUI children) and bound the post-exit
// I/O wait, so an orphan inheriting cmd's stdout/stderr pipe can never hang
// cmd.Wait() and the runner. See process.TreeKill. The PTY path below may
// override SysProcAttr, but never touches Cancel/WaitDelay.
treeKill := process.NewTreeKill(cmd)
var ppty *os.File
var tty *os.File
@@ -375,15 +360,9 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
if err := cmd.Start(); err != nil {
return err
}
// Capture the started process for tree-kill on cancellation: a Job Object on
// Windows (children spawned afterwards are auto-included) and the process
// group on Unix. On failure (e.g. Windows 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 {
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 {
killer.Store(k)
defer k.Close()
}
err = cmd.Wait()

View File

@@ -1,29 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build plan9
package container
import "os"
// processKiller falls back to single-process termination on platforms without
// a process-group / Job Object tree-kill. The Job Object (Windows) and process
// group (Unix) based tree-kills live in process_windows.go / process_unix.go;
// here we just kill the direct child, matching the previous default behaviour.
type processKiller struct {
p *os.Process
}
func newProcessKiller(p *os.Process) (*processKiller, error) {
return &processKiller{p: p}, nil
}
func (k *processKiller) Kill() error {
if k == nil || k.p == nil {
return nil
}
return k.p.Kill()
}
func (k *processKiller) Close() error { return nil }

View File

@@ -1,56 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows && !plan9
package container
import (
"errors"
"os"
"syscall"
)
// processKiller terminates a step process together with its whole process
// group, which is the Unix counterpart of the Windows Job Object tree-kill.
//
// Background: a step often launches a process tree (a shell that starts a child
// which in turn spawns further 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.
//
// Steps are started with Setpgid (or Setsid for the PTY path, see
// getSysProcAttr), which makes the step process the leader of a new process
// group whose ID equals its PID. Signalling the negative PID delivers to every
// process still in that group, so we can tear down the whole tree atomically on
// cancellation, which also closes the inherited pipe handles so cmd.Wait() can
// return.
type processKiller struct {
pgid int
}
// newProcessKiller captures the process group of p (an already-started
// process). Because the step is launched with Setpgid/Setsid, p is a group
// leader and its PGID equals its PID; children spawned afterwards stay in the
// same group unless they explicitly create their own.
func newProcessKiller(p *os.Process) (*processKiller, error) {
return &processKiller{pgid: p.Pid}, nil
}
// Kill sends SIGKILL to the entire process group (the step process and every
// descendant that stayed in the group). A missing group (ESRCH) means the
// processes already exited and is not treated as an error.
func (k *processKiller) Kill() error {
if k == nil || k.pgid <= 0 {
return nil
}
if err := syscall.Kill(-k.pgid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
// Close is a no-op on Unix; there is no job handle to release.
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
}
}
@@ -283,7 +283,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
}
}

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
}

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

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

View File

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

View File

@@ -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"
@@ -272,6 +274,36 @@ 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 {
logger := common.Logger(ctx)
@@ -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")

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

@@ -455,3 +455,50 @@ 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),
)
}

View File

@@ -0,0 +1,285 @@
// 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")
}

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(),
}
}
@@ -127,7 +127,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(),
}
}

View File

@@ -22,6 +22,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"
)
@@ -57,9 +58,10 @@ type jobInfo interface {
// 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) {
func reportStepError(ctx context.Context, rc *RunContext, err error) {
common.Logger(ctx).Errorf("##[error]%v", err)
common.SetJobError(ctx, err)
rc.markFailed()
}
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
@@ -117,9 +119,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
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 +131,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 +143,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
})
@@ -158,7 +160,12 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
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 +204,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 +312,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 +328,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 +367,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

View File

@@ -28,6 +28,7 @@ import (
"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) {
@@ -347,6 +348,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 +802,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

@@ -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) {
@@ -189,6 +222,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)
@@ -343,6 +379,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 {
@@ -381,7 +424,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
c := container.NewContainer(&container.NewContainerInput{
Name: serviceContainerName,
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: rc.ExprEval.Interpolate(ctx, spec.Image),
Image: serviceImage,
Username: username,
Password: password,
Cmd: interpolatedCmd,
@@ -471,7 +514,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),
@@ -900,12 +944,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,
}

View File

@@ -276,6 +276,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{}

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,68 @@ 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
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
}
// GetToken: Adapt to Gitea
@@ -248,7 +257,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 +315,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

@@ -114,13 +114,23 @@ 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,
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
})
@@ -312,7 +322,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

@@ -778,6 +778,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) {
@@ -812,3 +838,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

@@ -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"

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

12
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.1+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
@@ -22,22 +22,23 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-isatty v0.0.22
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_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/sys v0.47.0
golang.org/x/term v0.44.0
google.golang.org/protobuf v1.36.11
gotest.tools/v3 v3.5.2
@@ -84,7 +85,6 @@ 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/robfig/cron/v3 v3.0.1 // indirect

14
go.sum
View File

@@ -49,6 +49,10 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+4uf10QRSc=
github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.6.1+incompatible h1:oO7F4nn3Ovr/5TlfTUWFbMwBSS/B7Xs6Epv26gBrUP8=
github.com/docker/cli v29.6.1+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=
@@ -129,8 +133,12 @@ github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
@@ -209,6 +217,8 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ
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=
@@ -252,9 +262,9 @@ 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/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

View File

@@ -0,0 +1,40 @@
// 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 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

@@ -443,10 +443,11 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
NoSkipCheckout: execArgs.noSkipCheckout,
// 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
},

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

@@ -4,8 +4,12 @@
package cmd
import (
"os"
"testing"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/require"
"gotest.tools/v3/assert"
)
@@ -17,3 +21,136 @@ func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
})
assert.Error(t, err, "unsupported schema: invalid")
}
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{"ubuntu:vm:bad"}},
wantErr: "unsupported schema: vm",
},
{
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"}))
require.Error(t, validateLabels([]string{"ubuntu:host", "ubuntu:vm:bad"}))
}
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", "ubuntu:vm:bad"}
inputs := &registerInputs{}
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg))
// only the valid label survives
require.Equal(t, []string{"ubuntu:host"}, 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:vm:bad", emptyCfg))
require.Nil(t, 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) {
inputs := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
Token: "token",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "token", 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)
require.Nil(t, initInputs(&registerArgs{Labels: " "}).Labels)
}

View File

@@ -0,0 +1,132 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/metrics"
"gitea.com/gitea/runner/internal/pkg/process"
"gitea.com/gitea/runner/internal/pkg/report"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
log "github.com/sirupsen/logrus"
)
func (r *Runner) runPostTaskScript(ctx context.Context, reporter *report.Reporter, task *runnerv1.Task, workdir string) {
script := r.cfg.Runner.PostTaskScript
if script == "" {
return
}
timeout := r.cfg.Runner.PostTaskScriptTimeout
if timeout <= 0 {
timeout = config.DefaultPostTaskScriptTimeout
}
scriptCtx, cancel := postTaskScriptContext(ctx, timeout)
defer cancel()
env := r.postTaskScriptEnv(reporter, task, workdir)
log.Infof("running post-task script %q for task %d", script, task.Id)
cmd := exec.CommandContext(scriptCtx, script)
cmd.Env = envListFromMap(env)
cmd.SysProcAttr = process.SysProcAttr(script, false)
stdout := postTaskScriptLogWriter("stdout")
stderr := postTaskScriptLogWriter("stderr")
cmd.Stdout = stdout
cmd.Stderr = stderr
// Kill the script's whole process tree on cancellation and bound the post-exit
// I/O wait, so a backgrounded child inheriting cmd's stdout/stderr pipe can
// never hang cmd.Wait() and the runner. See process.TreeKill.
treeKill := process.NewTreeKill(cmd)
if err := cmd.Start(); err != nil {
log.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
return
}
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
log.Warnf("post-task script %q for task %d: process tree kill setup failed, falling back to single-process kill: %v", script, task.Id, kerr)
} else {
defer k.Close()
}
err := cmd.Wait()
// Flush any trailing, not-yet-newline-terminated output now that the I/O
// copiers have finished (cmd.Wait, bounded by WaitDelay above, guarantees it).
common.FlushWriter(stdout)
common.FlushWriter(stderr)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
log.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
return
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
log.Warnf("post-task script %q for task %d exited with code %d", script, task.Id, exitErr.ExitCode())
return
}
log.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
}
}
func postTaskScriptContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
// Detach from the task context's deadline and cancellation: the task has
// already finished by the time the post-task script runs, so the script must
// get its full configured timeout. Inheriting the task deadline would silently
// truncate that budget when the job completed close to its own timeout (and an
// already-cancelled task context would skip the script entirely).
// context.WithoutCancel keeps the context values while dropping the deadline.
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
}
func (r *Runner) postTaskScriptEnv(reporter *report.Reporter, task *runnerv1.Task, workdir string) map[string]string {
env := r.cloneEnvs()
env["GITEA_TASK_ID"] = strconv.FormatInt(task.Id, 10)
env["GITEA_WORKSPACE"] = workdir
// GITEA_JOB_RESULT shares the runner's canonical result vocabulary
// (success/failure/cancelled/skipped/unknown), the same strings the reporter
// parses and the metrics labels use.
env["GITEA_JOB_RESULT"] = metrics.ResultToStatusLabel(reporter.Result())
if v := task.Context.Fields["run_id"].GetStringValue(); v != "" {
env["GITEA_RUN_ID"] = v
}
if v := task.Context.Fields["repository"].GetStringValue(); v != "" {
env["GITEA_REPOSITORY"] = v
}
return env
}
func envListFromMap(env map[string]string) []string {
envList := make([]string, 0, len(env))
for k, v := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
}
return envList
}
// postTaskScriptLogWriter returns an io.Writer that logs the script's output one
// line at a time, tagged with the stream name. It is passed as cmd.Stdout/Stderr
// (rather than a StdoutPipe) so that cmd.WaitDelay governs the copying goroutine:
// a backgrounded process holding the pipe open can never block cmd.Wait()
// indefinitely. Flush any trailing partial line with common.FlushWriter after
// cmd.Wait() returns.
func postTaskScriptLogWriter(stream string) io.Writer {
return common.NewLineWriter(func(line string) bool {
log.Infof("post-task script %s: %s", stream, strings.TrimRight(line, "\r\n"))
return true
})
}

View File

@@ -0,0 +1,157 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/metrics"
"gitea.com/gitea/runner/internal/pkg/report"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
func TestRunPostTaskScriptSkippedWhenEmpty(t *testing.T) {
r := &Runner{
cfg: &config.Config{},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
task := &runnerv1.Task{Id: 1, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, r.cfg)
require.NotPanics(t, func() {
r.runPostTaskScript(ctx, reporter, task, "/workspace/owner/repo")
})
}
func TestRunPostTaskScriptNonZeroExitDoesNotPanic(t *testing.T) {
dir := t.TempDir()
scriptPath := filepath.Join(dir, "fail.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/bin/sh\nexit 2\n"), 0o700))
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.PostTaskScript = scriptPath
r := &Runner{cfg: cfg}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
task := &runnerv1.Task{Id: 1, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
require.NotPanics(t, func() {
r.runPostTaskScript(ctx, reporter, task, "/workspace/owner/repo")
})
}
func TestPostTaskScriptContextUsesFullTimeout(t *testing.T) {
const timeout = 5 * time.Minute
// A task context that finished close to its own deadline must not truncate the
// script's budget: the script should still get its full configured timeout.
near, cancelNear := context.WithTimeout(context.Background(), time.Second)
defer cancelNear()
scriptCtx, cancel := postTaskScriptContext(near, timeout)
defer cancel()
deadline, ok := scriptCtx.Deadline()
require.True(t, ok)
assert.Greater(t, time.Until(deadline), time.Minute, "script timeout truncated to task deadline")
// An already-cancelled task context must not cancel the script either.
cancelledCtx, cancelIt := context.WithCancel(context.Background())
cancelIt()
scriptCtx2, cancel2 := postTaskScriptContext(cancelledCtx, timeout)
defer cancel2()
assert.NoError(t, scriptCtx2.Err(), "script context inherited the cancelled task context")
}
func TestPostTaskScriptEnv(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
r := &Runner{
cfg: cfg,
envs: map[string]string{"BASE": "1"},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{
"run_id": "99",
"repository": "acme/widget",
})
require.NoError(t, err)
task := &runnerv1.Task{Id: 3, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
setReporterJobResult(t, reporter, runnerv1.Result_RESULT_FAILURE)
env := r.postTaskScriptEnv(reporter, task, "/tmp/workspace")
assert.Equal(t, "1", env["BASE"])
assert.Equal(t, "3", env["GITEA_TASK_ID"])
assert.Equal(t, "99", env["GITEA_RUN_ID"])
assert.Equal(t, "acme/widget", env["GITEA_REPOSITORY"])
assert.Equal(t, "/tmp/workspace", env["GITEA_WORKSPACE"])
assert.Equal(t, "failure", env["GITEA_JOB_RESULT"])
}
func TestRunPostTaskScriptIntegration(t *testing.T) {
dir := t.TempDir()
outFile := filepath.Join(dir, "out.txt")
scriptPath := filepath.Join(dir, "post-task.sh")
script := "#!/bin/sh\nprintf '%s %s %s' \"$GITEA_TASK_ID\" \"$GITEA_JOB_RESULT\" \"$CUSTOM\" > \"" + outFile + "\"\n"
require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o700))
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.PostTaskScript = scriptPath
r := &Runner{
cfg: cfg,
envs: map[string]string{"CUSTOM": "runner-env"},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
task := &runnerv1.Task{Id: 11, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
setReporterJobResult(t, reporter, runnerv1.Result_RESULT_SUCCESS)
r.runPostTaskScript(ctx, reporter, task, "/workspace/acme/repo")
content, err := os.ReadFile(outFile)
require.NoError(t, err)
assert.Equal(t, "11 success runner-env", string(content))
}
func setReporterJobResult(t *testing.T, reporter *report.Reporter, result runnerv1.Result) {
t.Helper()
require.NoError(t, reporter.Fire(&log.Entry{
Time: time.Now(),
Message: "job finished",
Data: log.Fields{
"stage": "Post",
"jobResult": metrics.ResultToStatusLabel(result),
},
}))
}

View File

@@ -22,6 +22,7 @@ import (
"gitea.com/gitea/runner/act/artifactcache"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model"
"gitea.com/gitea/runner/act/runner"
"gitea.com/gitea/runner/internal/pkg/client"
@@ -33,7 +34,7 @@ import (
"connectrpc.com/connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/moby/moby/api/types/container"
docker_container "github.com/moby/moby/api/types/container"
log "github.com/sirupsen/logrus"
)
@@ -298,6 +299,15 @@ func (r *Runner) getDefaultActionsURL(task *runnerv1.Task) string {
return giteaDefaultActionsURL
}
// isSelfHostedActionsURL reports whether actions resolve to this self-hosted Gitea
// (DEFAULT_ACTIONS_URL=self), i.e. gitea_default_actions_url is AppURL rather than
// github.com (which may be mirror-substituted by getDefaultActionsURL). Only then may the
// task token be attached to action clone URLs on the actions instance host.
func (r *Runner) isSelfHostedActionsURL(task *runnerv1.Task) bool {
giteaDefaultActionsURL := task.Context.Fields["gitea_default_actions_url"].GetStringValue()
return giteaDefaultActionsURL != "" && giteaDefaultActionsURL != "https://github.com"
}
func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.Reporter) (err error) {
defer func() {
if r := recover(); r != nil {
@@ -395,6 +405,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
maxLifetime = time.Until(deadline)
}
// shallow clones the requested ref at depth 1, otherwise 0 means a full clone
actionCloneDepth := 1
if r.cfg.Runner.ActionShallowClone != nil && !*r.cfg.Runner.ActionShallowClone {
actionCloneDepth = 0
}
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
if r.cfg.Container.BindWorkdir {
// Append the task ID to isolate concurrent jobs from the same repo.
@@ -417,31 +433,37 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent),
AllocatePTY: r.cfg.Runner.AllocatePTY,
ActionOfflineMode: r.cfg.Cache.OfflineMode,
ActionCloneDepth: actionCloneDepth,
ReuseContainers: false,
ForcePull: r.cfg.Container.ForcePull,
ForceRebuild: r.cfg.Container.ForceRebuild,
LogOutput: true,
JSONLogger: false,
Env: envs,
Secrets: task.Secrets,
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
AutoRemove: true,
NoSkipCheckout: true,
PresetGitHubContext: preset,
EventJSON: string(eventJSON),
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
ContainerMaxLifetime: maxLifetime,
CleanWorkdir: true,
ContainerNetworkMode: container.NetworkMode(r.cfg.Container.Network),
ContainerOptions: r.cfg.Container.Options,
ContainerDaemonSocket: r.cfg.Container.DockerHost,
Privileged: r.cfg.Container.Privileged,
DefaultActionInstance: r.getDefaultActionsURL(task),
PlatformPicker: r.labels.PickPlatform,
Vars: task.Vars,
ValidVolumes: r.cfg.Container.ValidVolumes,
InsecureSkipTLS: r.cfg.Runner.Insecure,
ReuseContainers: false,
ForcePull: r.cfg.Container.ForcePull,
ForceRebuild: r.cfg.Container.ForceRebuild,
LogOutput: true,
JSONLogger: false,
Env: envs,
Secrets: task.Secrets,
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
AutoRemove: true,
NoSkipCheckout: true,
PresetGitHubContext: preset,
EventJSON: string(eventJSON),
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
ContainerMaxLifetime: maxLifetime,
CleanWorkdir: true,
ContainerNetworkMode: docker_container.NetworkMode(r.cfg.Container.Network),
ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{
EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4,
EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6,
},
ContainerOptions: r.cfg.Container.Options,
ContainerDaemonSocket: r.cfg.Container.DockerHost,
Privileged: r.cfg.Container.Privileged,
DefaultActionInstance: r.getDefaultActionsURL(task),
DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task),
PlatformPicker: r.labels.PickPlatform,
Vars: task.Vars,
ValidVolumes: r.cfg.Container.ValidVolumes,
InsecureSkipTLS: r.cfg.Runner.Insecure,
}
rr, err := runner.New(runnerConfig)
@@ -470,6 +492,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
}
}
reporter.StopHeartbeats()
r.runPostTaskScript(ctx, reporter, task, workdir)
return execErr
}

View File

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

View File

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

View File

@@ -69,6 +69,9 @@ runner:
# and github_mirror is not empty. In this case,
# it replaces https://github.com with the value here, which is useful for some special network environments.
github_mirror: ''
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
# Set to false to clone the full history.
action_shallow_clone: true
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
@@ -83,39 +86,69 @@ runner:
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
# when a TTY is present.
allocate_pty: false
# Optional executable on the host, run once after each task's built-in cleanup
# (post-steps, container teardown, bind-workdir removal). Additive only.
#
# IMPORTANT: While this script runs the runner stops task heartbeats and stays
# offline from Gitea's perspective until the script exits. A script that never
# returns blocks new work until post_task_script_timeout kills it (default 5m).
# Keep scripts short; set post_task_script_timeout to a safe upper bound.
#
# Output -> runner process log (not the job log). Non-zero exit -> warning only.
# Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as
# the configured path; wrap PowerShell commands in a .cmd file instead.
# Full guide: docs/post-task-script.md
post_task_script: ''
# Hard limit on post_task_script runtime. Default if omitted: 5m.
post_task_script_timeout: 5m
cache:
# Enable cache server to use actions/cache.
# Enable the built-in cache server (used by actions/cache and similar actions).
enabled: true
# The directory to store the cache data.
# If it's empty, the cache data will be stored in $HOME/.cache/actcache.
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
# Ignored when external_server is set.
dir: ""
# The host of the cache server.
# It's not for the address to listen, but the address to connect from job containers.
# So 0.0.0.0 is a bad choice, leave it empty to detect automatically.
# Outbound IP or hostname that job containers use to reach this runner's cache server.
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
# If the runner itself runs in Docker, automatic detection can choose an
# address on the runner container's network that job containers cannot reach
# when the runner creates a separate per-job network. In that case, set this
# to a hostname/IP reachable from job containers, and set port to a fixed
# published port or put the job containers on a shared Docker network.
# Ignored when external_server is set.
host: ""
# The port of the cache server.
# 0 means to use a random available port.
# Port for the built-in cache server. 0 picks a random free port.
# Ignored when external_server is set.
port: 0
# The external cache server URL. Valid only when enable is true.
# If it's specified, runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
# The URL should generally end with "/".
# Requires external_secret below to be set to the same value on both this runner and the cache-server.
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
# Set on every runner that should share a cache pool. Must end with "/".
# Example: "http://cache-host:8088/"
# Requires external_secret (below) to match the value on the cache-server.
external_server: ""
# Shared secret between this runner and the external `gitea-runner cache-server`. Required when external_server
# (or `gitea-runner cache-server`) is in use: the runner pre-registers each job's ACTIONS_RUNTIME_TOKEN with the
# cache-server, and the cache-server enforces bearer auth + per-repo cache isolation.
# Shared secret between this runner and the external cache-server.
# Required when external_server is set. Must be identical on every runner and the cache-server.
# Generate with: openssl rand -hex 32
external_secret: ""
# When true, reuse a cached action instead of fetching from the remote on every job. Note: a moved tag
# (e.g. a re-tagged "v6") or an updated branch stays at the cached commit until its cache entry is removed.
# When true, reuse a cached action instead of fetching from the remote on every job.
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
# until its cache entry expires or is manually removed.
offline_mode: false
container:
# Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network.
# If it's empty, runner will create a network automatically.
# For dockerized runners using the built-in cache server, a custom shared
# network can be required so job containers can reach cache.host/cache.port.
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
network: ""
# network_create_options only apply when `network` is left empty and the runner
# auto-creates a per-job network that does not already exist. They have no effect
# when a custom `network` name is set, because that network is used as-is and never
# created by the runner. Omit the entire block to use Docker's defaults.
network_create_options:
enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
privileged: false
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).

View File

@@ -16,6 +16,12 @@ import (
"go.yaml.in/yaml/v4"
)
// DefaultPostTaskScriptTimeout is the fallback cap on how long the post-task
// script may run when post_task_script is set without an explicit timeout. It is
// applied both at config load (for a configured script) and at the point of use
// (so a programmatically built config still gets a sane bound).
const DefaultPostTaskScriptTimeout = 5 * time.Minute
// Log represents the configuration for logging.
type Log struct {
Level string `yaml:"level"` // Level indicates the logging level.
@@ -23,26 +29,29 @@ type Log struct {
// Runner represents the configuration for the runner.
type Runner struct {
File string `yaml:"file"` // File specifies the file path for the runner.
Capacity int `yaml:"capacity"` // Capacity specifies the capacity of the runner.
Envs map[string]string `yaml:"envs"` // Envs stores environment variables for the runner.
EnvFile string `yaml:"env_file"` // EnvFile specifies the path to the file containing environment variables for the runner.
Timeout time.Duration `yaml:"timeout"` // Timeout specifies the duration for runner timeout.
ShutdownTimeout time.Duration `yaml:"shutdown_timeout"` // ShutdownTimeout specifies the duration to wait for running jobs to complete during a shutdown of the runner.
Insecure bool `yaml:"insecure"` // Insecure indicates whether the runner operates in an insecure mode.
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories and orphaned host-mode scratch dirs older than this duration during idle cleanup.
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs stale-directory cleanup periodically while the runner is idle. Set to 0 to disable cleanup cadence.
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
StateReportInterval time.Duration `yaml:"state_report_interval"` // StateReportInterval specifies the interval for state reporting.
ReportCloseTimeout time.Duration `yaml:"report_close_timeout"` // ReportCloseTimeout caps each RPC attempt when flushing the final logs and task state at job completion, on a detached context so a server cancel can't block the acknowledgement.
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
File string `yaml:"file"` // File specifies the file path for the runner.
Capacity int `yaml:"capacity"` // Capacity specifies the capacity of the runner.
Envs map[string]string `yaml:"envs"` // Envs stores environment variables for the runner.
EnvFile string `yaml:"env_file"` // EnvFile specifies the path to the file containing environment variables for the runner.
Timeout time.Duration `yaml:"timeout"` // Timeout specifies the duration for runner timeout.
ShutdownTimeout time.Duration `yaml:"shutdown_timeout"` // ShutdownTimeout specifies the duration to wait for running jobs to complete during a shutdown of the runner.
Insecure bool `yaml:"insecure"` // Insecure indicates whether the runner operates in an insecure mode.
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories and orphaned host-mode scratch dirs older than this duration during idle cleanup.
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs stale-directory cleanup periodically while the runner is idle. Set to 0 to disable cleanup cadence.
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
StateReportInterval time.Duration `yaml:"state_report_interval"` // StateReportInterval specifies the interval for state reporting.
ReportCloseTimeout time.Duration `yaml:"report_close_timeout"` // ReportCloseTimeout caps each RPC attempt when flushing the final logs and task state at job completion, on a detached context so a server cancel can't block the acknowledgement.
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
ActionShallowClone *bool `yaml:"action_shallow_clone"` // ActionShallowClone fetches only the requested ref of an action repository at depth 1 instead of cloning every branch's full history. It is a pointer to distinguish between false and not set; if not set, it defaults to true.
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
}
// Cache represents the configuration for caching.
@@ -58,18 +67,24 @@ type Cache struct {
// Container represents the configuration for the container.
type Container struct {
Network string `yaml:"network"` // Network specifies the network for the container.
NetworkMode string `yaml:"network_mode"` // Deprecated: use Network instead. Could be removed after Gitea 1.20
Privileged bool `yaml:"privileged"` // Privileged indicates whether the container runs in privileged mode.
Options string `yaml:"options"` // Options specifies additional options for the container.
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the container's working directory.
ValidVolumes []string `yaml:"valid_volumes"` // ValidVolumes specifies the volumes (including bind mounts) can be mounted to containers.
DockerHost string `yaml:"docker_host"` // DockerHost specifies the Docker host. It overrides the value specified in environment variable DOCKER_HOST.
ForcePull bool `yaml:"force_pull"` // Pull docker image(s) even if already present
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts.
Network string `yaml:"network"` // Network specifies the network for the container.
NetworkCreateOptions ContainerNetworkCreateOptions `yaml:"network_create_options"` // Add options when the network need to be created by the runner
NetworkMode string `yaml:"network_mode"` // Deprecated: use Network instead. Could be removed after Gitea 1.20
Privileged bool `yaml:"privileged"` // Privileged indicates whether the container runs in privileged mode.
Options string `yaml:"options"` // Options specifies additional options for the container.
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the container's working directory.
ValidVolumes []string `yaml:"valid_volumes"` // ValidVolumes specifies the volumes (including bind mounts) can be mounted to containers.
DockerHost string `yaml:"docker_host"` // DockerHost specifies the Docker host. It overrides the value specified in environment variable DOCKER_HOST.
ForcePull bool `yaml:"force_pull"` // Pull docker image(s) even if already present
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts.
}
type ContainerNetworkCreateOptions struct {
EnableIPv4 *bool `yaml:"enable_ipv4"` // Enable or disable IPv4 for the network (true for docker by default)
EnableIPv6 *bool `yaml:"enable_ipv6"` // Enable or disable IPv6 for the network (false for docker by default)
}
// Host represents the configuration for the host.
@@ -137,6 +152,10 @@ func LoadDefault(file string) (*Config, error) {
if cfg.Runner.Timeout <= 0 {
cfg.Runner.Timeout = 3 * time.Hour
}
if cfg.Runner.ActionShallowClone == nil {
b := true
cfg.Runner.ActionShallowClone = &b
}
if cfg.Cache.Enabled == nil {
b := true
cfg.Cache.Enabled = &b
@@ -187,6 +206,9 @@ func LoadDefault(file string) (*Config, error) {
if cfg.Runner.ReportCloseTimeout <= 0 {
cfg.Runner.ReportCloseTimeout = 10 * time.Second
}
if cfg.Runner.PostTaskScript != "" && cfg.Runner.PostTaskScriptTimeout <= 0 {
cfg.Runner.PostTaskScriptTimeout = DefaultPostTaskScriptTimeout
}
if cfg.Metrics.Addr == "" {
cfg.Metrics.Addr = "127.0.0.1:9101"
}

View File

@@ -107,6 +107,34 @@ runner:
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
// invalid YAML to the canonical "parse config file" message rather than the
// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first.
func TestLoadDefault_LoadsPostTaskScript(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
post_task_script: /usr/local/bin/post-task.sh
post_task_script_timeout: 2m
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "/usr/local/bin/post-task.sh", cfg.Runner.PostTaskScript)
assert.Equal(t, 2*time.Minute, cfg.Runner.PostTaskScriptTimeout)
}
func TestLoadDefault_DefaultsPostTaskScriptTimeout(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
post_task_script: /usr/local/bin/post-task.sh
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout)
}
func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
@@ -117,3 +145,50 @@ func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
assert.Contains(t, err.Error(), "parse config file")
assert.NotContains(t, err.Error(), "defaults metadata")
}
func TestContainerNetworkCreateOptions(t *testing.T) {
// Verify that the enable_ipv4/enable_ipv6 YAML keys unmarshal into the *bool fields,
// distinguishing an explicit true/false from an omitted key (nil). A nil here is
// forwarded as-is to Docker, which applies its own default.
loadOptions := func(t *testing.T, yaml string) ContainerNetworkCreateOptions {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(yaml), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
return cfg.Container.NetworkCreateOptions
}
t.Run("enable_ipv6 true unmarshals to non-nil true", func(t *testing.T) {
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv6: true\n")
require.NotNil(t, opts.EnableIPv6)
assert.True(t, *opts.EnableIPv6)
})
t.Run("enable_ipv6 false unmarshals to non-nil false", func(t *testing.T) {
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv6: false\n")
require.NotNil(t, opts.EnableIPv6)
assert.False(t, *opts.EnableIPv6)
})
t.Run("enable_ipv4 false unmarshals to non-nil false", func(t *testing.T) {
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv4: false\n")
require.NotNil(t, opts.EnableIPv4)
assert.False(t, *opts.EnableIPv4)
})
t.Run("omitted keys stay nil", func(t *testing.T) {
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv4: true\n")
require.NotNil(t, opts.EnableIPv4)
assert.True(t, *opts.EnableIPv4)
assert.Nil(t, opts.EnableIPv6, "an omitted enable_ipv6 must remain nil so Docker's default applies")
})
t.Run("omitted block leaves both nil", func(t *testing.T) {
opts := loadOptions(t, "container:\n network: \"\"\n")
assert.Nil(t, opts.EnableIPv4)
assert.Nil(t, opts.EnableIPv6)
})
}

View File

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

View File

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

View File

@@ -61,3 +61,75 @@ func TestParse(t *testing.T) {
})
}
}
// mustParse parses the given label strings, failing the test on any error.
func mustParse(t *testing.T, strs ...string) Labels {
t.Helper()
ls := make(Labels, 0, len(strs))
for _, s := range strs {
l, err := Parse(s)
require.NoError(t, err)
ls = append(ls, l)
}
return ls
}
func TestRequireDocker(t *testing.T) {
tests := []struct {
name string
strs []string
want bool
}{
{"empty", nil, false},
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
{"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, mustParse(t, tt.strs...).RequireDocker())
})
}
}
func TestPickPlatform(t *testing.T) {
ls := mustParse(t,
"ubuntu:docker://node:18",
"self-hosted:host",
)
tests := []struct {
name string
runsOn []string
want string
}{
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
{"host maps to self-hosted marker", []string{"self-hosted"}, "-self-hosted"},
{"first match wins", []string{"self-hosted", "ubuntu"}, "-self-hosted"},
{"unknown falls back to default", []string{"windows"}, "docker.gitea.com/runner-images:ubuntu-latest"},
{"no runsOn falls back to default", nil, "docker.gitea.com/runner-images:ubuntu-latest"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, ls.PickPlatform(tt.runsOn))
})
}
}
func TestNames(t *testing.T) {
ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host")
require.Equal(t, []string{"ubuntu", "self-hosted"}, ls.Names())
require.Empty(t, Labels{}.Names())
}
func TestToStrings(t *testing.T) {
ls := mustParse(t,
"ubuntu:docker://node:18",
"self-hosted:host",
"bare",
)
require.Equal(t, []string{
"ubuntu:docker://node:18",
"self-hosted:host",
"bare:host",
}, ls.ToStrings())
}

View File

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

View File

@@ -0,0 +1,29 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build plan9
package process
import "os"
// Killer falls back to single-process termination on platforms without a
// process-group / Job Object tree-kill. The Job Object (Windows) and process
// group (Unix) based tree-kills live in killer_windows.go / killer_unix.go;
// here we just kill the direct child, matching the previous default behaviour.
type Killer struct {
p *os.Process
}
func NewKiller(p *os.Process) (*Killer, error) {
return &Killer{p: p}, nil
}
func (k *Killer) Kill() error {
if k == nil || k.p == nil {
return nil
}
return k.p.Kill()
}
func (k *Killer) Close() error { return nil }

View File

@@ -0,0 +1,56 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows && !plan9
package process
import (
"errors"
"os"
"syscall"
)
// Killer terminates a started process together with its whole process group,
// which is the Unix counterpart of the Windows Job Object tree-kill.
//
// Background: a process (a step or a post-task script) often launches a process
// tree (a shell that starts a child which in turn spawns further background
// processes). The default exec.CommandContext cancellation only kills the
// direct child, so cancelling left the rest of the tree running. Because those
// orphans inherited the parent's stdout/stderr pipe, cmd.Wait() also blocked
// forever and the runner hung.
//
// Processes are started with Setpgid (or Setsid for the PTY path, see
// SysProcAttr), which makes the process the leader of a new process group whose
// ID equals its PID. Signalling the negative PID delivers to every process
// still in that group, so we can tear down the whole tree atomically on
// cancellation, which also closes the inherited pipe handles so cmd.Wait() can
// return.
type Killer struct {
pgid int
}
// NewKiller captures the process group of p (an already-started process).
// Because the process is launched with Setpgid/Setsid, p is a group leader and
// its PGID equals its PID; children spawned afterwards stay in the same group
// unless they explicitly create their own.
func NewKiller(p *os.Process) (*Killer, error) {
return &Killer{pgid: p.Pid}, nil
}
// Kill sends SIGKILL to the entire process group (the process and every
// descendant that stayed in the group). A missing group (ESRCH) means the
// processes already exited and is not treated as an error.
func (k *Killer) Kill() error {
if k == nil || k.pgid <= 0 {
return nil
}
if err := syscall.Kill(-k.pgid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
// Close is a no-op on Unix; there is no job handle to release.
func (k *Killer) Close() error { return nil }

View File

@@ -3,7 +3,7 @@
//go:build !windows && !plan9
package container
package process
import (
"fmt"
@@ -47,11 +47,12 @@ func processAlive(pid int) bool {
return true
}
// TestProcessKillerKillsTree verifies that a process group captured by the
// killer is terminated together with a child the step spawns afterwards. This
// mirrors a step that launches a child which spawns further processes, where
// cancelling the job must take down the whole tree, not just the direct child.
func TestProcessKillerKillsTree(t *testing.T) {
// TestKillerKillsTree verifies that a process group captured by the killer is
// terminated together with a child the process spawns afterwards. This mirrors
// a step or post-task script that launches a child which spawns further
// processes, where cancelling must take down the whole tree, not just the
// direct child.
func TestKillerKillsTree(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "child.pid")
@@ -60,8 +61,8 @@ func TestProcessKillerKillsTree(t *testing.T) {
// child stays in the parent's process group, so the group kill must reach it.
script := fmt.Sprintf(`sleep 600 & echo $! > %q; sleep 600`, pidFile)
cmd := exec.Command("/bin/sh", "-c", script)
// Launch as its own process-group leader, exactly like a real step does (see
// getSysProcAttr), so the killer's PGID == the process PID.
// Launch as its own process-group leader, exactly like a real process does
// (see SysProcAttr), so the killer's PGID == the process PID.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
require.NoError(t, cmd.Start())
t.Cleanup(func() {
@@ -69,7 +70,7 @@ func TestProcessKillerKillsTree(t *testing.T) {
_ = cmd.Wait()
})
killer, err := newProcessKiller(cmd.Process)
killer, err := NewKiller(cmd.Process)
require.NoError(t, err)
defer killer.Close()

View File

@@ -0,0 +1,72 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"os"
"golang.org/x/sys/windows"
)
// Killer terminates a started process together with its entire descendant tree
// via a Windows Job Object.
//
// Background: a process (a step or a post-task script) 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 left the rest of the tree running. Because
// those orphans inherited the parent's stdout/stderr pipe, cmd.Wait() also
// blocked forever and the runner hung.
//
// Assigning the 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 Killer struct {
job windows.Handle
}
// NewKiller 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 NewKiller(p *os.Process) (*Killer, 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 func() { _ = windows.CloseHandle(h) }()
if err := windows.AssignProcessToJobObject(job, h); err != nil {
_ = windows.CloseHandle(job)
return nil, err
}
return &Killer{job: job}, nil
}
// Kill terminates every process currently assigned to the job (the process and
// all of its descendants).
func (k *Killer) 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 *Killer) Close() error {
if k == nil || k.job == 0 {
return nil
}
h := k.job
k.job = 0
return windows.CloseHandle(h)
}

View File

@@ -1,7 +1,7 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
package process
import (
"fmt"
@@ -23,7 +23,7 @@ func processAlive(pid int) bool {
if err != nil {
return false
}
defer windows.CloseHandle(h)
defer func() { _ = windows.CloseHandle(h) }()
var code uint32
if err := windows.GetExitCodeProcess(h, &code); err != nil {
return false
@@ -32,11 +32,11 @@ func processAlive(pid int) bool {
return code == stillActive
}
// TestProcessKillerKillsTree verifies that a process assigned to the Job Object
// is terminated together with a child it spawns afterwards. This mirrors a step
// that launches a child which spawns further processes, where cancelling the
// job must take down the whole tree, not just the direct child.
func TestProcessKillerKillsTree(t *testing.T) {
// TestKillerKillsTree verifies that a process assigned to the Job Object is
// terminated together with a child it spawns afterwards. This mirrors a step or
// post-task script that launches a child which spawns further processes, where
// cancelling must take down the whole tree, not just the direct child.
func TestKillerKillsTree(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "child.pid")
@@ -50,7 +50,7 @@ func TestProcessKillerKillsTree(t *testing.T) {
require.NoError(t, cmd.Start())
t.Cleanup(func() { _ = cmd.Process.Kill() })
killer, err := newProcessKiller(cmd.Process)
killer, err := NewKiller(cmd.Process)
require.NoError(t, err)
defer killer.Close()

View File

@@ -0,0 +1,17 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build plan9
package process
import "syscall"
// SysProcAttr returns the platform attributes used to start a process. Plan 9
// has no process-group tree-kill (see Killer), so we only request a new rfork
// note group here.
func SysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
return &syscall.SysProcAttr{
Rfork: syscall.RFNOTEG,
}
}

View File

@@ -0,0 +1,24 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows && !plan9
package process
import "syscall"
// SysProcAttr returns the platform attributes used to start a process so that a
// Killer can later tear down its whole process tree. On Unix the process becomes
// the leader of a new process group (or session, for the PTY path), so a
// signal to the negative PID reaches every descendant that stayed in the group.
func SysProcAttr(_ string, tty bool) *syscall.SysProcAttr {
if tty {
return &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
}
}
return &syscall.SysProcAttr{
Setpgid: true,
}
}

View File

@@ -0,0 +1,23 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows && !plan9
package process
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSysProcAttrUnixModes(t *testing.T) {
plain := SysProcAttr("", false)
require.True(t, plain.Setpgid)
require.False(t, plain.Setsid)
tty := SysProcAttr("", true)
require.True(t, tty.Setsid)
require.True(t, tty.Setctty)
require.False(t, tty.Setpgid)
}

View File

@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import "syscall"
// SysProcAttr returns the platform attributes used to start a process so that a
// Killer can later tear down its whole process tree. On Windows the process is
// placed in a new process group; the descendant tree is reclaimed via the Job
// Object set up by NewKiller.
func SysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
return &syscall.SysProcAttr{CmdLine: cmdLine, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
}

View File

@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"os"
"os/exec"
"sync/atomic"
"time"
)
// treeKillWaitDelay bounds how long Wait lingers for the command's I/O pipes to
// drain after the process exits before force-closing them and returning. It also
// covers a command that backgrounds a process holding a pipe open after a clean
// exit.
const treeKillWaitDelay = 10 * time.Second
// TreeKill wires an exec.Cmd so that cancelling it tears down the command's
// whole process tree (see Killer) rather than only the direct child, and bounds
// the post-exit I/O wait so a leftover pipe writer can never hang cmd.Wait.
//
// Background: a command often launches a process tree (a shell that starts a
// child which spawns further background processes). The default
// exec.CommandContext 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 caller.
//
// Callers still set cmd.SysProcAttr (via SysProcAttr) themselves, because the
// value differs between the plain and PTY execution paths.
type TreeKill struct {
killer atomic.Pointer[Killer]
}
// NewTreeKill sets cmd.Cancel and cmd.WaitDelay. Call it before cmd.Start, then
// call Capture once after a successful Start.
func NewTreeKill(cmd *exec.Cmd) *TreeKill {
t := &TreeKill{}
cmd.Cancel = func() error {
if k := t.killer.Load(); k != nil {
return k.Kill()
}
if cmd.Process != nil {
return cmd.Process.Kill()
}
return nil
}
cmd.WaitDelay = treeKillWaitDelay
return t
}
// Capture assigns the started process (and the descendants it spawns) to a
// Killer so cancellation can reach the whole tree — a Job Object on Windows
// (children spawned afterwards are auto-included) and the process group on Unix.
// Call it once after cmd.Start. On failure the command falls back to the default
// single-process kill and the returned error is for logging only; WaitDelay
// still bounds the wait. The returned Killer should be closed when the command
// finishes (Close is nil-safe).
func (t *TreeKill) Capture(p *os.Process) (*Killer, error) {
k, err := NewKiller(p)
if err != nil {
return nil, err
}
t.killer.Store(k)
return k, nil
}

View File

@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"context"
"os/exec"
"testing"
"github.com/stretchr/testify/require"
)
func TestNewTreeKillConfiguresCommand(t *testing.T) {
cmd := exec.CommandContext(context.Background(), "sleep", "1")
tk := NewTreeKill(cmd)
require.NotNil(t, tk)
require.NotNil(t, cmd.Cancel)
require.Equal(t, treeKillWaitDelay, cmd.WaitDelay)
require.NoError(t, cmd.Cancel())
}
func TestTreeKillCaptureStoresKiller(t *testing.T) {
cmd := exec.CommandContext(context.Background(), "sleep", "10")
cmd.SysProcAttr = SysProcAttr("", false)
tk := NewTreeKill(cmd)
require.NoError(t, cmd.Start())
defer func() { _ = cmd.Wait() }()
killer, err := tk.Capture(cmd.Process)
require.NoError(t, err)
require.NotNil(t, killer)
require.NoError(t, cmd.Cancel())
require.NoError(t, killer.Close())
}

View File

@@ -44,11 +44,13 @@ type Reporter struct {
// so the gauge skips no-op Set calls when the buffer size is unchanged.
lastLogBufferRows int
state *runnerv1.TaskState
stateChanged bool
stateMu sync.RWMutex
outputs sync.Map
daemon chan struct{}
state *runnerv1.TaskState
stateChanged bool
stateMu sync.RWMutex
outputs sync.Map
daemon chan struct{}
heartbeatStop chan struct{}
heartbeatStopOnce sync.Once
// Unix-nanos of the last successful UpdateTask. Atomic so the heartbeat
// guard in ReportState reads it without contending stateMu.
@@ -99,7 +101,8 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
state: &runnerv1.TaskState{
Id: task.Id,
},
daemon: make(chan struct{}),
daemon: make(chan struct{}),
heartbeatStop: make(chan struct{}),
}
if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" {
@@ -273,6 +276,15 @@ func (r *Reporter) RunDaemon() {
go r.runDaemonLoop()
}
// StopHeartbeats stops periodic UpdateTask heartbeats without cancelling the
// task context. Close() still delivers the final flush. Safe to call multiple
// times and when the context is already cancelled.
func (r *Reporter) StopHeartbeats() {
r.heartbeatStopOnce.Do(func() {
close(r.heartbeatStop)
})
}
func (r *Reporter) stopLatencyTimer(active *bool, timer *time.Timer) {
if *active {
if !timer.Stop() {
@@ -339,6 +351,12 @@ func (r *Reporter) runDaemonLoop() {
// delivers the final flush on a detached context (flushFinal).
close(r.daemon)
return
case <-r.heartbeatStop:
// Stop heartbeating during post-task script execution. Close() still
// delivers the final flush on a detached context (flushFinal).
close(r.daemon)
return
}
r.stateMu.RLock()

View File

@@ -921,3 +921,137 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
assert.True(t, foundCancelled, "final log must contain a 'Cancelled' row")
assert.False(t, foundEarlyTermination, "final log must not contain 'Early termination' on the cancel path")
}
// TestReporter_StopHeartbeats verifies that StopHeartbeats ends periodic
// UpdateTask heartbeats while Close() still flushes the final state.
func TestReporter_StopHeartbeats(t *testing.T) {
var updateTaskCalls atomic.Int64
client := mocks.NewClient(t)
client.On("UpdateLog", mock.Anything, mock.Anything).Maybe().Return(
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
}), nil
},
)
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
updateTaskCalls.Add(1)
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.StateReportInterval = 20 * time.Millisecond
cfg.Runner.LogReportInterval = time.Hour
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.RunDaemon()
reporter.stateMu.Lock()
reporter.stateChanged = true
reporter.state.Result = runnerv1.Result_RESULT_SUCCESS
reporter.state.StoppedAt = timestamppb.Now()
reporter.stateMu.Unlock()
require.Eventually(t, func() bool {
return updateTaskCalls.Load() >= 1
}, time.Second, 5*time.Millisecond, "daemon must send at least one UpdateTask before StopHeartbeats")
beforeStop := updateTaskCalls.Load()
reporter.StopHeartbeats()
select {
case <-reporter.daemon:
case <-time.After(time.Second):
t.Fatal("StopHeartbeats must stop the daemon loop")
}
time.Sleep(3 * cfg.Runner.StateReportInterval)
assert.Equal(t, beforeStop, updateTaskCalls.Load(),
"UpdateTask must not be called after StopHeartbeats")
require.NoError(t, reporter.Close(""))
assert.Greater(t, updateTaskCalls.Load(), beforeStop,
"Close() must still send a final UpdateTask after StopHeartbeats")
}
func TestAppendIfNotNil(t *testing.T) {
var s []*int
s = appendIfNotNil(s, nil)
assert.Empty(t, s)
v := 7
s = appendIfNotNil(s, &v)
require.Len(t, s, 1)
assert.Equal(t, &v, s[0])
s = appendIfNotNil(s, nil)
require.Len(t, s, 1)
}
func TestReporter_Levels(t *testing.T) {
assert.Equal(t, log.AllLevels, (&Reporter{}).Levels())
}
func TestReporter_Result(t *testing.T) {
r := &Reporter{state: &runnerv1.TaskState{Result: runnerv1.Result_RESULT_SUCCESS}}
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, r.Result())
}
func TestReporter_SetOutputs(t *testing.T) {
r := &Reporter{state: &runnerv1.TaskState{}}
r.SetOutputs(map[string]string{"foo": "bar"})
got, ok := r.outputs.Load("foo")
require.True(t, ok)
assert.Equal(t, "bar", got)
// first value wins: a later write to the same key is ignored
r.SetOutputs(map[string]string{"foo": "baz"})
got, _ = r.outputs.Load("foo")
assert.Equal(t, "bar", got)
// keys longer than 255 chars are dropped
longKey := strings.Repeat("k", 256)
r.SetOutputs(map[string]string{longKey: "v"})
_, ok = r.outputs.Load(longKey)
assert.False(t, ok)
}
func TestReporter_EffectiveCloseTimeout(t *testing.T) {
assert.Equal(t, 10*time.Second, (&Reporter{}).effectiveCloseTimeout())
assert.Equal(t, 5*time.Second, (&Reporter{closeTimeout: 5 * time.Second}).effectiveCloseTimeout())
}
func TestReporter_ParseResult(t *testing.T) {
r := &Reporter{}
tests := []struct {
name string
input any
want runnerv1.Result
wantOk bool
}{
{"job result string", "success", runnerv1.Result_RESULT_SUCCESS, true},
{"failure string", "failure", runnerv1.Result_RESULT_FAILURE, true},
{"step result stringer", runnerv1.Result_RESULT_SKIPPED, runnerv1.Result_RESULT_UNSPECIFIED, false},
{"unknown string", "bogus", runnerv1.Result_RESULT_UNSPECIFIED, false},
{"unsupported type", 123, runnerv1.Result_RESULT_UNSPECIFIED, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := r.parseResult(tt.input)
assert.Equal(t, tt.wantOk, ok)
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -0,0 +1,13 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ver
import "testing"
func TestVersion(t *testing.T) {
// version defaults to "dev" and is overridden at build time via -ldflags
if got := Version(); got != version {
t.Errorf("Version() = %q, want %q", got, version)
}
}

162
tools/coverage-report.ts Normal file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env node
// Turns a `go test -coverprofile` file into a human-friendly Markdown report:
// an overall total, a per-package summary sorted worst-first, and collapsible
// per-file details for each package.
//
// Coverage is statement-weighted (covered statements / total statements),
// matching `go tool cover -func`'s total, instead of naively averaging
// per-function percentages.
//
// Usage: node ./tools/coverage-report.ts -i coverage.txt -o .tmp/coverage.md
import {readFileSync, writeFileSync} from 'node:fs';
import {basename, dirname} from 'node:path';
import {argv, exit, stderr} from 'node:process';
const modulePrefix = 'gitea.com/gitea/runner/';
type Counter = {
covered: number;
total: number;
};
function percent(c: Counter): number {
if (c.total === 0) {
return 0;
}
return (c.covered / c.total) * 100;
}
function addCounter(a: Counter, b: Counter): Counter {
return {covered: a.covered + b.covered, total: a.total + b.total};
}
function parseArgs(): {input: string; output: string} {
let input = 'coverage.txt';
let output = '.tmp/coverage.md';
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '-i' && argv[i + 1]) {
input = argv[++i];
} else if (arg === '-o' && argv[i + 1]) {
output = argv[++i];
}
}
return {input, output};
}
function parseProfile(name: string): Map<string, Counter> {
const files = new Map<string, Counter>();
const content = readFileSync(name, 'utf8');
for (const line of content.split('\n')) {
if (line === '' || line.startsWith('mode:')) {
continue;
}
// Format: path:start.col,end.col numStmt count
const colon = line.lastIndexOf(':');
const fields = line.trimEnd().split(/\s+/);
if (colon < 0 || fields.length < 3) {
continue;
}
const file = line.slice(0, colon).replace(modulePrefix, '');
const stmts = Number.parseInt(fields.at(-2)!, 10);
const count = Number.parseInt(fields.at(-1)!, 10);
if (Number.isNaN(stmts) || Number.isNaN(count)) {
continue;
}
const c = files.get(file) ?? {covered: 0, total: 0};
c.total += stmts;
if (count > 0) {
c.covered += stmts;
}
files.set(file, c);
}
return files;
}
function render(files: Map<string, Counter>): string {
const pkgCounts = new Map<string, Counter>();
const pkgFiles = new Map<string, string[]>();
let total: Counter = {covered: 0, total: 0};
for (const [file, c] of files) {
const pkg = dirname(file);
pkgCounts.set(pkg, addCounter(pkgCounts.get(pkg) ?? {covered: 0, total: 0}, c));
const names = pkgFiles.get(pkg) ?? [];
names.push(file);
pkgFiles.set(pkg, names);
total = addCounter(total, c);
}
const pkgs = [...pkgCounts.keys()];
pkgs.sort((a, b) => {
const ci = pkgCounts.get(a)!;
const cj = pkgCounts.get(b)!;
const pi = percent(ci);
const pj = percent(cj);
if (pi !== pj) {
return pi - pj;
}
if (ci.total !== cj.total) {
return cj.total - ci.total;
}
return a.localeCompare(b);
});
const lines: string[] = [];
lines.push('# Coverage\n');
lines.push(
`**Total: ${percent(total).toFixed(1)}%** &nbsp;·&nbsp; ${total.covered} / ${total.total} statements covered &nbsp;·&nbsp; ${pkgs.length} packages\n`,
);
lines.push('## Packages\n');
lines.push('| Package | Coverage | Statements |');
lines.push('|---------|---------:|-----------:|');
for (const pkg of pkgs) {
const c = pkgCounts.get(pkg)!;
lines.push(`| ${pkg} | ${percent(c).toFixed(1)}% | ${c.covered} / ${c.total} |`);
}
lines.push('');
lines.push('## Files\n');
for (const pkg of pkgs) {
const c = pkgCounts.get(pkg)!;
const names = [...(pkgFiles.get(pkg) ?? [])];
names.sort((a, b) => {
const ci = files.get(a)!;
const cj = files.get(b)!;
const pi = percent(ci);
const pj = percent(cj);
if (pi !== pj) {
return pi - pj;
}
return a.localeCompare(b);
});
lines.push(
`<details><summary><strong>${pkg}</strong> — ${percent(c).toFixed(1)}% (${c.covered}/${c.total})</summary>\n`,
);
lines.push('| File | Coverage | Statements |');
lines.push('|------|---------:|-----------:|');
for (const file of names) {
const fc = files.get(file)!;
lines.push(`| ${basename(file)} | ${percent(fc).toFixed(1)}% | ${fc.covered} / ${fc.total} |`);
}
lines.push('\n</details>\n');
}
return lines.join('\n');
}
function main(): void {
const {input, output} = parseArgs();
try {
const files = parseProfile(input);
writeFileSync(output, render(files), {mode: 0o644});
} catch (err) {
stderr.write(`coverage-report: ${err}\n`);
exit(1);
}
}
main();