mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 08:54:21 +02:00
Compare commits
19 Commits
33e6d1d8ff
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f6b6d90a | ||
|
|
cdcea87a45 | ||
|
|
3c4bcf3ebf | ||
|
|
e22d3fa263 | ||
|
|
99bc50d538 | ||
|
|
8f72c60afa | ||
|
|
4e7fd1c68a | ||
|
|
bd41a367fe | ||
|
|
c566013db4 | ||
|
|
40e021309a | ||
|
|
d3b3519dea | ||
|
|
6bdcb54828 | ||
|
|
007717956a | ||
|
|
df0370f8bf | ||
|
|
5f0636faad | ||
|
|
4997f33b5f | ||
|
|
2963716953 | ||
|
|
3996d6d032 | ||
|
|
205af7cd01 |
@@ -18,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
DOCKER_LATEST: latest
|
DOCKER_LATEST: latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
# to ~/.docker with the stale credentials.
|
# to ~/.docker with the stale credentials.
|
||||||
DOCKER_CONFIG: /tmp/docker-noauth
|
DOCKER_CONFIG: /tmp/docker-noauth
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.5.3-dind AS dind
|
FROM docker:29.6.0-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.5.3-dind-rootless AS dind-rootless
|
FROM docker:29.6.0-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -38,12 +38,15 @@ endif
|
|||||||
ifeq ($(OS), Windows_NT)
|
ifeq ($(OS), Windows_NT)
|
||||||
GOFLAGS := -v -buildmode=exe
|
GOFLAGS := -v -buildmode=exe
|
||||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||||
|
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||||
else ifeq ($(OS), Windows)
|
else ifeq ($(OS), Windows)
|
||||||
GOFLAGS := -v -buildmode=exe
|
GOFLAGS := -v -buildmode=exe
|
||||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||||
|
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||||
else
|
else
|
||||||
GOFLAGS := -v
|
GOFLAGS := -v
|
||||||
EXECUTABLE ?= $(EXECUTABLE)
|
EXECUTABLE ?= $(EXECUTABLE)
|
||||||
|
GO_ENV_WINDOWS := GOOS=windows
|
||||||
endif
|
endif
|
||||||
|
|
||||||
STORED_VERSION_FILE := VERSION
|
STORED_VERSION_FILE := VERSION
|
||||||
@@ -108,12 +111,17 @@ deps-tools: ## install tool dependencies
|
|||||||
wait
|
wait
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go ## lint everything
|
lint: lint-go lint-go-windows ## lint everything
|
||||||
|
|
||||||
.PHONY: lint-go
|
.PHONY: lint-go
|
||||||
lint-go: ## lint go files
|
lint-go: ## lint go files
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
$(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
|
.PHONY: lint-go-fix
|
||||||
lint-go-fix: ## lint go files and fix issues
|
lint-go-fix: ## lint go files and fix issues
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -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.
|
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
|
#### 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
|
- 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
|
- 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
|
### Example Deployments
|
||||||
|
|
||||||
Check out the [examples](examples) directory for sample deployment types.
|
Check out the [examples](examples) directory for sample deployment types.
|
||||||
|
|||||||
@@ -257,6 +257,10 @@ type NewGitCloneExecutorInput struct {
|
|||||||
Token string
|
Token string
|
||||||
OfflineMode bool
|
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
|
// For Gitea
|
||||||
InsecureSkipTLS bool
|
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 {
|
if err != nil {
|
||||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -364,6 +368,16 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
pullOptions.InsecureSkipTLS = true
|
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 {
|
if !isOfflineMode {
|
||||||
err = r.Fetch(&fetchOptions)
|
err = r.Fetch(&fetchOptions)
|
||||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
@@ -431,11 +445,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
|
|
||||||
reusedMsg := ""
|
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 {
|
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||||
}
|
}
|
||||||
} else if reused {
|
case isOfflineMode && reused:
|
||||||
reusedMsg = " (reused in offline mode)"
|
reusedMsg = " (reused in offline mode)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,3 +484,53 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -380,6 +381,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 {
|
func gitCmd(args ...string) error {
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", args...)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ func JobError(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SetJobError(ctx context.Context, err 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
|
// WithJobErrorContainer adds a value to the context as a container for an error
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import (
|
|||||||
// LineHandler is a callback function for handling a line
|
// LineHandler is a callback function for handling a line
|
||||||
type LineHandler func(line string) bool
|
type LineHandler func(line string) bool
|
||||||
|
|
||||||
|
// Flusher is implemented by writers that buffer a trailing, not-yet-terminated
|
||||||
|
// line. Callers should flush once the underlying stream has reached EOF so the
|
||||||
|
// final line (when it is not newline-terminated) is not lost.
|
||||||
|
type Flusher interface {
|
||||||
|
Flush()
|
||||||
|
}
|
||||||
|
|
||||||
type lineWriter struct {
|
type lineWriter struct {
|
||||||
buffer bytes.Buffer
|
buffer bytes.Buffer
|
||||||
handlers []LineHandler
|
handlers []LineHandler
|
||||||
@@ -24,6 +31,14 @@ func NewLineWriter(handlers ...LineHandler) io.Writer {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so
|
||||||
|
// callers can flush an io.Writer without knowing its concrete type.
|
||||||
|
func FlushWriter(w io.Writer) {
|
||||||
|
if f, ok := w.(Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||||
pBuf := bytes.NewBuffer(p)
|
pBuf := bytes.NewBuffer(p)
|
||||||
written := 0
|
written := 0
|
||||||
@@ -44,6 +59,17 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
|||||||
return written, nil
|
return written, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush emits any buffered, not-yet-newline-terminated content as a final line.
|
||||||
|
// It is safe to call multiple times; subsequent calls with an empty buffer are
|
||||||
|
// no-ops.
|
||||||
|
func (lw *lineWriter) Flush() {
|
||||||
|
if lw.buffer.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lw.handleLine(lw.buffer.String())
|
||||||
|
lw.buffer.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
func (lw *lineWriter) handleLine(line string) {
|
func (lw *lineWriter) handleLine(line string) {
|
||||||
for _, h := range lw.handlers {
|
for _, h := range lw.handlers {
|
||||||
ok := h(line)
|
ok := h(line)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -39,3 +40,33 @@ func TestLineWriter(t *testing.T) {
|
|||||||
assert.Equal(" and another\n", lines[2])
|
assert.Equal(" and another\n", lines[2])
|
||||||
assert.Equal("last line\n", lines[3])
|
assert.Equal("last line\n", lines[3])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLineWriterFlush(t *testing.T) {
|
||||||
|
lines := make([]string, 0)
|
||||||
|
lineHandler := func(s string) bool {
|
||||||
|
lines = append(lines, s)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
lineWriter := NewLineWriter(lineHandler)
|
||||||
|
|
||||||
|
assert := assert.New(t)
|
||||||
|
_, err := lineWriter.Write([]byte("complete line\npartial line without newline"))
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing pattern from nektos/act
|
||||||
|
|
||||||
|
// Only the newline-terminated line is emitted before flushing.
|
||||||
|
assert.Equal([]string{"complete line\n"}, lines)
|
||||||
|
|
||||||
|
// Flushing emits the buffered, not-yet-terminated trailing line.
|
||||||
|
FlushWriter(lineWriter)
|
||||||
|
assert.Equal([]string{"complete line\n", "partial line without newline"}, lines)
|
||||||
|
|
||||||
|
// Flushing again is a no-op: nothing is buffered.
|
||||||
|
FlushWriter(lineWriter)
|
||||||
|
assert.Len(lines, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlushWriterIgnoresNonFlusher(t *testing.T) {
|
||||||
|
// FlushWriter must be a safe no-op for writers that do not buffer lines.
|
||||||
|
assert.NotPanics(t, func() { FlushWriter(io.Discard) })
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ type NewDockerBuildExecutorInput struct {
|
|||||||
Platform string
|
Platform string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
||||||
|
type NewDockerNetworkCreateExecutorInput struct {
|
||||||
|
EnableIPv4 *bool
|
||||||
|
EnableIPv6 *bool
|
||||||
|
}
|
||||||
|
|
||||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||||
type NewDockerPullExecutorInput struct {
|
type NewDockerPullExecutorInput struct {
|
||||||
Image string
|
Image string
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/moby/moby/client"
|
"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 {
|
return func(ctx context.Context) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,6 +39,8 @@ func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
|||||||
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||||
Driver: "bridge",
|
Driver: "bridge",
|
||||||
Scope: "local",
|
Scope: "local",
|
||||||
|
EnableIPv4: opts.EnableIPv4,
|
||||||
|
EnableIPv6: opts.EnableIPv6,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
@@ -45,6 +46,13 @@ import (
|
|||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
||||||
|
// finish draining a container's output before returning, so that neither a
|
||||||
|
// cancellation (waitForCommand) nor a normal container exit (wait) truncates
|
||||||
|
// the tail of the log. It is a safety bound: in the common case the stream
|
||||||
|
// reaches EOF and the goroutine returns well before this elapses.
|
||||||
|
const drainGracePeriod = 2 * time.Second
|
||||||
|
|
||||||
// NewContainer creates a reference to a container
|
// NewContainer creates a reference to a container
|
||||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||||
cr := new(containerReference)
|
cr := new(containerReference)
|
||||||
@@ -229,6 +237,10 @@ type containerReference struct {
|
|||||||
input *NewContainerInput
|
input *NewContainerInput
|
||||||
UID int
|
UID int
|
||||||
GID int
|
GID int
|
||||||
|
// attachDone is closed by the attach() streaming goroutine once it has
|
||||||
|
// drained and flushed the container's output. wait() blocks on it so the
|
||||||
|
// tail of the log lands before the step proceeds.
|
||||||
|
attachDone chan struct{}
|
||||||
LinuxContainerEnvironmentExtensions
|
LinuxContainerEnvironmentExtensions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,7 +742,9 @@ func (cr *containerReference) tryReadGID() common.Executor {
|
|||||||
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
|
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
cmdResponse := make(chan error)
|
// Buffered so the copy goroutine never blocks on send if the grace-period
|
||||||
|
// drain below times out and no one is left to receive.
|
||||||
|
cmdResponse := make(chan error, 1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
var outWriter io.Writer
|
var outWriter io.Writer
|
||||||
@@ -749,6 +763,11 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
|||||||
} else {
|
} else {
|
||||||
_, err = io.Copy(outWriter, resp.Reader)
|
_, err = io.Copy(outWriter, resp.Reader)
|
||||||
}
|
}
|
||||||
|
// Flush any buffered, not-yet-newline-terminated trailing line so the
|
||||||
|
// final line of a command's output is not lost (e.g. an error message
|
||||||
|
// printed without a trailing newline before the process exits).
|
||||||
|
common.FlushWriter(outWriter)
|
||||||
|
common.FlushWriter(errWriter)
|
||||||
cmdResponse <- err
|
cmdResponse <- err
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -760,6 +779,16 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
|||||||
logger.Warnf("Failed to send CTRL+C: %+s", err)
|
logger.Warnf("Failed to send CTRL+C: %+s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Give the copy goroutine a brief grace period to drain output already
|
||||||
|
// produced by the command before we return, so cancellation does not
|
||||||
|
// truncate the tail of the log. The goroutine exits once the hijacked
|
||||||
|
// stream is closed by resp.Close() in the caller's defer.
|
||||||
|
select {
|
||||||
|
case <-cmdResponse:
|
||||||
|
case <-time.After(drainGracePeriod):
|
||||||
|
logger.Warn("Timed out draining command output after cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
// we return the context canceled error to prevent other steps
|
// we return the context canceled error to prevent other steps
|
||||||
// from executing
|
// from executing
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -945,14 +974,23 @@ func (cr *containerReference) attach() common.Executor {
|
|||||||
if errWriter == nil {
|
if errWriter == nil {
|
||||||
errWriter = os.Stderr
|
errWriter = os.Stderr
|
||||||
}
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
cr.attachDone = done
|
||||||
go func() {
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
var copyErr error
|
||||||
if !isTerminal || os.Getenv("NORAW") != "" {
|
if !isTerminal || os.Getenv("NORAW") != "" {
|
||||||
_, err = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
|
_, copyErr = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
|
||||||
} else {
|
} else {
|
||||||
_, err = io.Copy(outWriter, out.Reader)
|
_, copyErr = io.Copy(outWriter, out.Reader)
|
||||||
}
|
}
|
||||||
if err != nil {
|
// Flush any buffered, not-yet-newline-terminated trailing line once
|
||||||
common.Logger(ctx).Error(err)
|
// the stream reaches EOF, so the final line of the container's
|
||||||
|
// output is not lost when it is not newline-terminated.
|
||||||
|
common.FlushWriter(outWriter)
|
||||||
|
common.FlushWriter(errWriter)
|
||||||
|
if copyErr != nil {
|
||||||
|
common.Logger(ctx).Error(copyErr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
@@ -991,6 +1029,18 @@ func (cr *containerReference) wait() common.Executor {
|
|||||||
|
|
||||||
logger.Debugf("Return status: %v", statusCode)
|
logger.Debugf("Return status: %v", statusCode)
|
||||||
|
|
||||||
|
// The container has exited; wait for the attach() streaming goroutine to
|
||||||
|
// finish draining and flushing its output before returning, so the tail
|
||||||
|
// of the log is not lost. Bounded so a stuck stream cannot hang the step.
|
||||||
|
if cr.attachDone != nil {
|
||||||
|
select {
|
||||||
|
case <-cr.attachDone:
|
||||||
|
case <-time.After(drainGracePeriod):
|
||||||
|
logger.Warn("Timed out draining container output")
|
||||||
|
}
|
||||||
|
cr.attachDone = nil
|
||||||
|
}
|
||||||
|
|
||||||
if statusCode == 0 {
|
if statusCode == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
@@ -89,6 +91,11 @@ func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts
|
|||||||
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
||||||
|
args := m.Called(ctx, containerID, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
||||||
args := m.Called(ctx, containerID, opts)
|
args := m.Called(ctx, containerID, opts)
|
||||||
return args.Get(0).(mobyclient.ContainerWaitResult)
|
return args.Get(0).(mobyclient.ContainerWaitResult)
|
||||||
@@ -206,6 +213,71 @@ func TestDockerExecFailure(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stdcopyFrame wraps payload in a single Docker multiplexed-stream frame, the
|
||||||
|
// format StdCopy expects: an 8-byte header (stream type + 4-byte big-endian
|
||||||
|
// length) followed by the payload.
|
||||||
|
func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
||||||
|
b := make([]byte, 8+len(payload))
|
||||||
|
b[0] = byte(stream)
|
||||||
|
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
|
||||||
|
copy(b[8:], payload)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDockerAttachFlushesTrailingLine verifies that wait() blocks until the
|
||||||
|
// attach() streaming goroutine has drained and flushed the container's output,
|
||||||
|
// so a final line without a trailing newline is not lost.
|
||||||
|
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
logWriter := common.NewLineWriter(func(s string) bool {
|
||||||
|
lines = append(lines, s)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
||||||
|
Return(mobyclient.ContainerAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
|
Conn: &mockConn{},
|
||||||
|
Reader: bufio.NewReader(framed),
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
|
statusCh <- container.WaitResponse{StatusCode: 0}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
||||||
|
Return(mobyclient.ContainerWaitResult{
|
||||||
|
Result: (<-chan container.WaitResponse)(statusCh),
|
||||||
|
Error: (<-chan error)(errCh),
|
||||||
|
})
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
Stdout: logWriter,
|
||||||
|
Stderr: logWriter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, cr.attach()(ctx))
|
||||||
|
require.NoError(t, cr.wait()(ctx))
|
||||||
|
|
||||||
|
// wait() must have blocked until the goroutine drained AND flushed; the
|
||||||
|
// trailing, non-newline-terminated line must therefore be present. Reading
|
||||||
|
// lines here is race-free because wait() synchronizes on attachDone, which
|
||||||
|
// the goroutine closes after the final append.
|
||||||
|
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDockerWaitFailure(t *testing.T) {
|
func TestDockerWaitFailure(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
|||||||
@@ -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 func(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
"gitea.com/gitea/runner/act/lookpath"
|
"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/helper/polyfill"
|
||||||
"github.com/go-git/go-billy/v5/osfs"
|
"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.Stdin = tty
|
||||||
cmd.Stdout = tty
|
cmd.Stdout = tty
|
||||||
cmd.Stderr = tty
|
cmd.Stderr = tty
|
||||||
cmd.SysProcAttr = getSysProcAttr(cmdline, true)
|
cmd.SysProcAttr = process.SysProcAttr(cmdline, true)
|
||||||
return ppty, tty, nil
|
return ppty, tty, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,30 +322,14 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
cmd.Env = envList
|
cmd.Env = envList
|
||||||
cmd.Stderr = e.StdOut
|
cmd.Stderr = e.StdOut
|
||||||
cmd.Dir = wd
|
cmd.Dir = wd
|
||||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
||||||
|
|
||||||
// On Windows a step often launches a process tree (a shell that starts a
|
// Kill the step's whole process tree on cancellation (a step often launches a
|
||||||
// child which spawns further GUI or background processes). The default
|
// shell that spawns further background or GUI children) and bound the post-exit
|
||||||
// context cancellation only kills the direct child, leaving the rest of the
|
// I/O wait, so an orphan inheriting cmd's stdout/stderr pipe can never hang
|
||||||
// tree running; and because the orphans inherit cmd's stdout/stderr pipe,
|
// cmd.Wait() and the runner. See process.TreeKill. The PTY path below may
|
||||||
// cmd.Wait() would block forever, hanging the runner. Kill the whole tree
|
// override SysProcAttr, but never touches Cancel/WaitDelay.
|
||||||
// via a Job Object on cancellation, and bound the wait so a leftover pipe
|
treeKill := process.NewTreeKill(cmd)
|
||||||
// writer can never hang Wait indefinitely.
|
|
||||||
var killer atomic.Pointer[processKiller]
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
cmd.Cancel = func() error {
|
|
||||||
if k := killer.Load(); k != nil {
|
|
||||||
return k.Kill()
|
|
||||||
}
|
|
||||||
if cmd.Process != nil {
|
|
||||||
return cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Once the step process has exited, give its I/O pipes at most this long
|
|
||||||
// to drain before Wait force-closes them and returns (Go's WaitDelay).
|
|
||||||
cmd.WaitDelay = 10 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
var ppty *os.File
|
var ppty *os.File
|
||||||
var tty *os.File
|
var tty *os.File
|
||||||
@@ -375,18 +360,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
|
||||||
// Assign the started process to a Job Object so cmd.Cancel can kill the
|
|
||||||
// whole descendant tree. Children spawned afterwards are auto-included.
|
|
||||||
// On failure (e.g. nested-job restrictions) we fall back to the default
|
|
||||||
// single-process kill; WaitDelay + end-of-job cleanup still apply.
|
|
||||||
if k, kerr := newProcessKiller(cmd.Process); kerr != nil {
|
|
||||||
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||||
} else {
|
} else {
|
||||||
killer.Store(k)
|
|
||||||
defer k.Close()
|
defer k.Close()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
err = cmd.Wait()
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var exitErr *exec.ExitError
|
var exitErr *exec.ExitError
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
//go:build !windows
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import "os"
|
|
||||||
|
|
||||||
// processKiller is a no-op on non-Windows platforms. The Job Object based
|
|
||||||
// tree-kill is only wired in on Windows (see exec()); elsewhere the default
|
|
||||||
// exec.CommandContext cancellation and Setpgid handling apply.
|
|
||||||
type processKiller struct{}
|
|
||||||
|
|
||||||
func newProcessKiller(_ *os.Process) (*processKiller, error) { return &processKiller{}, nil }
|
|
||||||
|
|
||||||
func (k *processKiller) Kill() error { return nil }
|
|
||||||
|
|
||||||
func (k *processKiller) Close() error { return nil }
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -8,23 +8,10 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/creack/pty"
|
"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) {
|
func openPty() (*os.File, *os.File, error) {
|
||||||
return pty.Open()
|
return pty.Open()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,8 @@ package container
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
|
||||||
return &syscall.SysProcAttr{
|
|
||||||
Setpgid: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func openPty() (*os.File, *os.File, error) {
|
func openPty() (*os.File, *os.File, error) {
|
||||||
return nil, nil, errors.New("Unsupported")
|
return nil, nil, errors.New("Unsupported")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,8 @@ package container
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
|
||||||
return &syscall.SysProcAttr{
|
|
||||||
Rfork: syscall.RFNOTEG,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func openPty() (*os.File, *os.File, error) {
|
func openPty() (*os.File, *os.File, error) {
|
||||||
return nil, nil, errors.New("Unsupported")
|
return nil, nil, errors.New("Unsupported")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,8 @@ package container
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"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) {
|
func openPty() (*os.File, *os.File, error) {
|
||||||
return nil, nil, errors.New("Unsupported")
|
return nil, nil, errors.New("Unsupported")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pr
|
|||||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||||
|
|
||||||
for _, needs := range jobNeeds {
|
for _, needs := range jobNeeds {
|
||||||
if jobs[needs].Result != "success" {
|
if jobs[needs].NeedsResult() != "success" {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,7 +283,7 @@ func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pr
|
|||||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||||
|
|
||||||
for _, needs := range jobNeeds {
|
for _, needs := range jobNeeds {
|
||||||
if jobs[needs].Result == "failure" {
|
if jobs[needs].NeedsResult() == "failure" {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func LookPath2(file string, lenv Env) (string, error) {
|
|||||||
var exts []string
|
var exts []string
|
||||||
x := lenv.Getenv(`PATHEXT`)
|
x := lenv.Getenv(`PATHEXT`)
|
||||||
if x != "" {
|
if x != "" {
|
||||||
for _, e := range strings.Split(strings.ToLower(x), `;`) {
|
for e := range strings.SplitSeq(strings.ToLower(x), `;`) {
|
||||||
if e == "" {
|
if e == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ type Job struct {
|
|||||||
If yaml.Node `yaml:"if"`
|
If yaml.Node `yaml:"if"`
|
||||||
Steps []*Step `yaml:"steps"`
|
Steps []*Step `yaml:"steps"`
|
||||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||||
|
RawContinueOnError string `yaml:"continue-on-error"`
|
||||||
Services map[string]*ContainerSpec `yaml:"services"`
|
Services map[string]*ContainerSpec `yaml:"services"`
|
||||||
Strategy *Strategy `yaml:"strategy"`
|
Strategy *Strategy `yaml:"strategy"`
|
||||||
RawContainer yaml.Node `yaml:"container"`
|
RawContainer yaml.Node `yaml:"container"`
|
||||||
@@ -207,6 +208,34 @@ type Job struct {
|
|||||||
RawSecrets yaml.Node `yaml:"secrets"`
|
RawSecrets yaml.Node `yaml:"secrets"`
|
||||||
RawPermissions yaml.Node `yaml:"permissions"`
|
RawPermissions yaml.Node `yaml:"permissions"`
|
||||||
Result string
|
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
|
// Strategy for the job
|
||||||
|
|||||||
@@ -32,6 +32,32 @@ func TestStepCloneIsolatesMutableFields(t *testing.T) {
|
|||||||
assert.Equal(t, "original", orig.With["arg"], "With map must not be shared with the clone")
|
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 TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
||||||
yaml := `
|
yaml := `
|
||||||
name: local-action-docker-url
|
name: local-action-docker-url
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -272,6 +274,36 @@ func removeGitIgnore(ctx context.Context, directory string) error {
|
|||||||
return nil
|
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
|
// TODO: break out parts of function to reduce complexicity
|
||||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
|
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
|
||||||
logger := common.Logger(ctx)
|
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
|
// Apply forcePull only for prebuild docker images
|
||||||
forcePull = rc.Config.ForcePull
|
forcePull = rc.Config.ForcePull
|
||||||
} else {
|
} else {
|
||||||
// "-dockeraction" enshures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
|
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction)
|
||||||
image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest")
|
|
||||||
image = "act-" + strings.TrimLeft(image, "-")
|
|
||||||
image = strings.ToLower(image)
|
|
||||||
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
||||||
|
|
||||||
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
||||||
|
|||||||
@@ -455,3 +455,50 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
|||||||
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
285
act/runner/cancellation_test.go
Normal file
285
act/runner/cancellation_test.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -48,8 +48,11 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
if resumeCommand != "" && command != resumeCommand {
|
if resumeCommand != "" && command != resumeCommand {
|
||||||
// There should not be any emojis in the log output for Gitea.
|
// There should not be any emojis in the log output for Gitea.
|
||||||
// The code in the switch statement is the same.
|
// The code in the switch statement is the same.
|
||||||
|
// Return true (not false) so the line still reaches the raw_output
|
||||||
|
// log handler; otherwise everything between ::stop-commands:: and
|
||||||
|
// its end token is silently dropped from the step log.
|
||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
arg = UnescapeCommandData(arg)
|
arg = UnescapeCommandData(arg)
|
||||||
kvPairs = unescapeKvPairs(kvPairs)
|
kvPairs = unescapeKvPairs(kvPairs)
|
||||||
|
|||||||
@@ -28,6 +28,29 @@ func TestSetEnv(t *testing.T) {
|
|||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||||
|
a := assert.New(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
rc := new(RunContext)
|
||||||
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
|
// Stop command processing until the matching end token is seen.
|
||||||
|
a.True(handler("::stop-commands::my-end-token\n"))
|
||||||
|
|
||||||
|
// A command-shaped line while stopped must not be executed (env unchanged),
|
||||||
|
// but must still return true so it reaches the raw_output log handler and is
|
||||||
|
// not dropped from the step log.
|
||||||
|
a.True(handler("::set-env name=x::valz\n"))
|
||||||
|
a.NotContains(rc.Env, "x")
|
||||||
|
|
||||||
|
// The matching end token resumes command processing.
|
||||||
|
a.True(handler("::my-end-token::\n"))
|
||||||
|
|
||||||
|
// Commands are processed again after resuming.
|
||||||
|
a.True(handler("::set-env name=y::valy\n"))
|
||||||
|
a.Equal("valy", rc.Env["y"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetOutput(t *testing.T) {
|
func TestSetOutput(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
for _, needs := range jobNeeds {
|
for _, needs := range jobNeeds {
|
||||||
using[needs] = exprparser.Needs{
|
using[needs] = exprparser.Needs{
|
||||||
Outputs: jobs[needs].Outputs,
|
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 {
|
for _, needs := range jobNeeds {
|
||||||
using[needs] = exprparser.Needs{
|
using[needs] = exprparser.Needs{
|
||||||
Outputs: jobs[needs].Outputs,
|
Outputs: jobs[needs].Outputs,
|
||||||
Result: jobs[needs].Result,
|
Result: jobs[needs].NeedsResult(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,9 +58,10 @@ type jobInfo interface {
|
|||||||
|
|
||||||
// reportStepError emits the GitHub Actions ##[error] annotation and records
|
// reportStepError emits the GitHub Actions ##[error] annotation and records
|
||||||
// the error against the job so the job is reported as failed.
|
// 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.Logger(ctx).Errorf("##[error]%v", err)
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
|
rc.markFailed()
|
||||||
}
|
}
|
||||||
|
|
||||||
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
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
|
rc.CurrentStepIndex = stepIdx
|
||||||
preErr := preExec(ctx)
|
preErr := preExec(ctx)
|
||||||
if preErr != nil {
|
if preErr != nil {
|
||||||
reportStepError(ctx, preErr)
|
reportStepError(ctx, rc, preErr)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
reportStepError(ctx, ctx.Err())
|
reportStepError(ctx, rc, ctx.Err())
|
||||||
}
|
}
|
||||||
return preErr
|
return preErr
|
||||||
}))
|
}))
|
||||||
@@ -129,9 +131,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
rc.CurrentStepIndex = stepIdx
|
rc.CurrentStepIndex = stepIdx
|
||||||
err := stepExec(ctx)
|
err := stepExec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reportStepError(ctx, err)
|
reportStepError(ctx, rc, err)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
reportStepError(ctx, ctx.Err())
|
reportStepError(ctx, rc, ctx.Err())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}))
|
}))
|
||||||
@@ -141,9 +143,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
rc.CurrentStepIndex = stepIdx
|
rc.CurrentStepIndex = stepIdx
|
||||||
err := postFn(ctx)
|
err := postFn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reportStepError(ctx, err)
|
reportStepError(ctx, rc, err)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
reportStepError(ctx, ctx.Err())
|
reportStepError(ctx, rc, ctx.Err())
|
||||||
}
|
}
|
||||||
return 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 {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
var err error
|
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
|
// 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)
|
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -197,25 +204,107 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
pipeline := make([]common.Executor, 0)
|
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
|
||||||
pipeline = append(pipeline, preSteps...)
|
|
||||||
pipeline = append(pipeline, steps...)
|
|
||||||
|
|
||||||
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
|
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
|
||||||
Finally(func(ctx context.Context) error {
|
Finally(func(ctx context.Context) error {
|
||||||
var cancel context.CancelFunc
|
// Record an interrupt (backstop for interrupts that land outside the main
|
||||||
if ctx.Err() == context.Canceled {
|
// step loop) so the post steps observe the cancelled/failed job status.
|
||||||
// in case of an aborted run, we still should execute the
|
rc.markInterrupted(ctx.Err())
|
||||||
// post steps to allow cleanup.
|
postCtx, cancel := postStepsContext(ctx)
|
||||||
ctx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), 5*time.Minute)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
return postExecutor(postCtx)
|
||||||
return postExecutor(ctx)
|
|
||||||
}).
|
}).
|
||||||
Finally(info.interpolateOutputs()).
|
Finally(info.interpolateOutputs()).
|
||||||
Finally(info.closeContainer()))
|
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) {
|
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
||||||
logger := common.Logger(ctx)
|
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
|
// read-modify-write of the job result so a failing combination is not lost-updated by a
|
||||||
// concurrent succeeding one.
|
// concurrent succeeding one.
|
||||||
job := rc.Run.Job()
|
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 {
|
jobResult := func() string {
|
||||||
defer lockJob(job)()
|
defer lockJob(job)()
|
||||||
result := "success"
|
result := "success"
|
||||||
@@ -233,6 +328,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
}
|
}
|
||||||
if !success {
|
if !success {
|
||||||
result = "failure"
|
result = "failure"
|
||||||
|
job.SetContinueOnError(continueOnError)
|
||||||
}
|
}
|
||||||
info.result(result)
|
info.result(result)
|
||||||
return 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) {
|
func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
||||||
if rc == nil || rc.JobContainer == nil || rc.Config == nil {
|
if rc == nil || rc.JobContainer == nil || rc.Config == nil {
|
||||||
return
|
return
|
||||||
@@ -462,6 +584,11 @@ func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, execu
|
|||||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||||
|
|
||||||
|
// Flush any buffered, not-yet-newline-terminated trailing line once the
|
||||||
|
// step has finished, so the final line of the step's output is not lost
|
||||||
|
// when it is not newline-terminated.
|
||||||
|
defer common.FlushWriter(logWriter)
|
||||||
|
|
||||||
return executor(ctx)
|
return executor(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestJobExecutor(t *testing.T) {
|
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) {
|
func TestHasJobSummaryCapability(t *testing.T) {
|
||||||
assert.True(t, hasJobSummaryCapability("cache,job-summary artifacts"))
|
assert.True(t, hasJobSummaryCapability("cache,job-summary artifacts"))
|
||||||
assert.True(t, hasJobSummaryCapability("cache,\njob-summary\tartifacts"))
|
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())
|
require.NoError(t, tw.Close())
|
||||||
return buf.Bytes()
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory,
|
|||||||
Dir: targetDirectory,
|
Dir: targetDirectory,
|
||||||
Token: token,
|
Token: token,
|
||||||
OfflineMode: rc.Config.ActionOfflineMode,
|
OfflineMode: rc.Config.ActionOfflineMode,
|
||||||
|
Depth: rc.Config.ActionCloneDepth,
|
||||||
})(ctx)
|
})(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,39 @@ type RunContext struct {
|
|||||||
// captured before execution so each matrix combo interpolates from the originals rather
|
// 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.
|
// than from a sibling's already-resolved values written into the shared Job.Outputs.
|
||||||
outputTemplate map[string]string
|
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) {
|
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 job := rc.Run.Job(); job != nil {
|
||||||
if container := job.Container(); container != nil {
|
if container := job.Container(); container != nil {
|
||||||
for _, v := range container.Volumes {
|
for _, v := range container.Volumes {
|
||||||
|
if rc.ExprEval != nil {
|
||||||
|
v = rc.ExprEval.Interpolate(context.Background(), v)
|
||||||
|
}
|
||||||
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
||||||
// Bind anonymous volume or host file.
|
// Bind anonymous volume or host file.
|
||||||
binds = append(binds, v)
|
binds = append(binds, v)
|
||||||
@@ -471,7 +507,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
rc.pullServicesImages(rc.Config.ForcePull),
|
rc.pullServicesImages(rc.Config.ForcePull),
|
||||||
rc.JobContainer.Pull(rc.Config.ForcePull),
|
rc.JobContainer.Pull(rc.Config.ForcePull),
|
||||||
rc.stopJobContainer(),
|
rc.stopJobContainer(),
|
||||||
container.NewDockerNetworkCreateExecutor(networkName).IfBool(createAndDeleteNetwork),
|
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
|
||||||
|
IfBool(createAndDeleteNetwork),
|
||||||
rc.startServiceContainers(networkName),
|
rc.startServiceContainers(networkName),
|
||||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
rc.JobContainer.Start(false),
|
rc.JobContainer.Start(false),
|
||||||
@@ -900,12 +937,21 @@ func trimToLen(s string, l int) string {
|
|||||||
|
|
||||||
func (rc *RunContext) getJobContext() *model.JobContext {
|
func (rc *RunContext) getJobContext() *model.JobContext {
|
||||||
jobStatus := "success"
|
jobStatus := "success"
|
||||||
|
if rc.jobFailed {
|
||||||
|
jobStatus = "failure"
|
||||||
|
}
|
||||||
for _, stepStatus := range rc.StepResults {
|
for _, stepStatus := range rc.StepResults {
|
||||||
if stepStatus.Conclusion == model.StepStatusFailure {
|
if stepStatus.Conclusion == model.StepStatusFailure {
|
||||||
jobStatus = "failure"
|
jobStatus = "failure"
|
||||||
break
|
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{
|
return &model.JobContext{
|
||||||
Status: jobStatus,
|
Status: jobStatus,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,6 +276,37 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
|
{"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 {
|
for _, testcase := range tests {
|
||||||
t.Run(testcase.name, func(t *testing.T) {
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
job := &model.Job{}
|
job := &model.Job{}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
@@ -32,6 +33,7 @@ type Config struct {
|
|||||||
Workdir string // path to working directory
|
Workdir string // path to working directory
|
||||||
ActionCacheDir string // path used for caching action contents
|
ActionCacheDir string // path used for caching action contents
|
||||||
ActionOfflineMode bool // when offline, use cached 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
|
BindWorkdir bool // bind the workdir to the job container
|
||||||
EventName string // name of event to run
|
EventName string // name of event to run
|
||||||
EventPath string // path to JSON file to use for event.json in containers
|
EventPath string // path to JSON file to use for event.json in containers
|
||||||
@@ -68,6 +70,7 @@ type Config struct {
|
|||||||
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
|
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
|
||||||
Matrix map[string]map[string]bool // Matrix config to run
|
Matrix map[string]map[string]bool // Matrix config to run
|
||||||
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
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
|
ActionCache ActionCache // Use a custom ActionCache Implementation
|
||||||
|
|
||||||
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
||||||
@@ -248,7 +251,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
return err
|
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
|
// Run all matrix combinations of this job, then drop its aggregation mutex: the
|
||||||
@@ -303,7 +309,7 @@ func handleFailure(plan *model.Plan) common.Executor {
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
for _, stage := range plan.Stages {
|
for _, stage := range plan.Stages {
|
||||||
for _, run := range stage.Runs {
|
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())
|
return fmt.Errorf("Job '%s' failed", run.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,13 +114,23 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
|
|
||||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
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, cloneURL) {
|
||||||
|
token = github.Token
|
||||||
|
}
|
||||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
||||||
URL: sar.remoteAction.CloneURL(defaultActionURL),
|
URL: cloneURL,
|
||||||
Ref: sar.remoteAction.Ref,
|
Ref: sar.remoteAction.Ref,
|
||||||
Dir: actionDir,
|
Dir: actionDir,
|
||||||
Token: token,
|
Token: token,
|
||||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||||
|
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||||
|
|
||||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
||||||
})
|
})
|
||||||
@@ -312,7 +322,7 @@ func (ra *remoteAction) IsCheckout() bool {
|
|||||||
|
|
||||||
func newRemoteAction(action string) *remoteAction {
|
func newRemoteAction(action string) *remoteAction {
|
||||||
// support http(s)://host/owner/repo@v3
|
// 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 {
|
if after, ok := strings.CutPrefix(action, schema); ok {
|
||||||
splits := strings.SplitN(after, "/", 2)
|
splits := strings.SplitN(after, "/", 2)
|
||||||
if len(splits) != 2 {
|
if len(splits) != 2 {
|
||||||
|
|||||||
@@ -778,6 +778,32 @@ func Test_newRemoteAction(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantCloneURL: "http://gitea.com/actions/aws",
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.action, func(t *testing.T) {
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
155
docs/post-task-script.md
Normal file
155
docs/post-task-script.md
Normal 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
|
||||||
8
go.mod
8
go.mod
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v29.5.3+incompatible
|
github.com/docker/cli v29.6.0+incompatible
|
||||||
github.com/docker/go-connections v0.7.0
|
github.com/docker/go-connections v0.7.0
|
||||||
github.com/go-git/go-billy/v5 v5.9.0
|
github.com/go-git/go-billy/v5 v5.9.0
|
||||||
github.com/go-git/go-git/v5 v5.19.1
|
github.com/go-git/go-git/v5 v5.19.1
|
||||||
@@ -22,8 +22,8 @@ require (
|
|||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||||
github.com/mattn/go-isatty v0.0.22
|
github.com/mattn/go-isatty v0.0.22
|
||||||
github.com/moby/go-archive v0.2.0
|
github.com/moby/go-archive v0.2.0
|
||||||
github.com/moby/moby/api v1.54.2
|
github.com/moby/moby/api v1.55.0
|
||||||
github.com/moby/moby/client v0.4.1
|
github.com/moby/moby/client v0.5.0
|
||||||
github.com/moby/patternmatcher v0.6.1
|
github.com/moby/patternmatcher v0.6.1
|
||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.1
|
||||||
@@ -35,7 +35,7 @@ require (
|
|||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
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
|
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||||
golang.org/x/sys v0.46.0
|
golang.org/x/sys v0.46.0
|
||||||
golang.org/x/term v0.44.0
|
golang.org/x/term v0.44.0
|
||||||
|
|||||||
10
go.sum
10
go.sum
@@ -49,6 +49,8 @@ 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/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 h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
|
||||||
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
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/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
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/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||||
@@ -129,8 +131,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/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 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
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 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.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 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
@@ -209,6 +215,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.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
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.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.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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
@@ -253,8 +261,6 @@ 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 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
|||||||
132
internal/app/run/post_task_script.go
Normal file
132
internal/app/run/post_task_script.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
157
internal/app/run/post_task_script_test.go
Normal file
157
internal/app/run/post_task_script_test.go
Normal 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),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/artifactcache"
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.com/gitea/runner/act/runner"
|
"gitea.com/gitea/runner/act/runner"
|
||||||
"gitea.com/gitea/runner/internal/pkg/client"
|
"gitea.com/gitea/runner/internal/pkg/client"
|
||||||
@@ -33,7 +34,7 @@ import (
|
|||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
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"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -395,6 +396,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
maxLifetime = time.Until(deadline)
|
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, "/")
|
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
|
||||||
if r.cfg.Container.BindWorkdir {
|
if r.cfg.Container.BindWorkdir {
|
||||||
// Append the task ID to isolate concurrent jobs from the same repo.
|
// Append the task ID to isolate concurrent jobs from the same repo.
|
||||||
@@ -417,6 +424,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent),
|
ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent),
|
||||||
AllocatePTY: r.cfg.Runner.AllocatePTY,
|
AllocatePTY: r.cfg.Runner.AllocatePTY,
|
||||||
ActionOfflineMode: r.cfg.Cache.OfflineMode,
|
ActionOfflineMode: r.cfg.Cache.OfflineMode,
|
||||||
|
ActionCloneDepth: actionCloneDepth,
|
||||||
|
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
ForcePull: r.cfg.Container.ForcePull,
|
ForcePull: r.cfg.Container.ForcePull,
|
||||||
@@ -433,7 +441,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
|
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
|
||||||
ContainerMaxLifetime: maxLifetime,
|
ContainerMaxLifetime: maxLifetime,
|
||||||
CleanWorkdir: true,
|
CleanWorkdir: true,
|
||||||
ContainerNetworkMode: container.NetworkMode(r.cfg.Container.Network),
|
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,
|
ContainerOptions: r.cfg.Container.Options,
|
||||||
ContainerDaemonSocket: r.cfg.Container.DockerHost,
|
ContainerDaemonSocket: r.cfg.Container.DockerHost,
|
||||||
Privileged: r.cfg.Container.Privileged,
|
Privileged: r.cfg.Container.Privileged,
|
||||||
@@ -470,6 +482,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reporter.StopHeartbeats()
|
||||||
|
r.runPostTaskScript(ctx, reporter, task, workdir)
|
||||||
|
|
||||||
return execErr
|
return execErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ runner:
|
|||||||
# and github_mirror is not empty. In this case,
|
# 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.
|
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
||||||
github_mirror: ''
|
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.
|
# 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"
|
# 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 .
|
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
||||||
@@ -83,31 +86,47 @@ runner:
|
|||||||
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
|
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
|
||||||
# when a TTY is present.
|
# when a TTY is present.
|
||||||
allocate_pty: false
|
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:
|
cache:
|
||||||
# Enable cache server to use actions/cache.
|
# Enable the built-in cache server (used by actions/cache and similar actions).
|
||||||
enabled: true
|
enabled: true
|
||||||
# The directory to store the cache data.
|
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
|
||||||
# If it's empty, the cache data will be stored in $HOME/.cache/actcache.
|
# Ignored when external_server is set.
|
||||||
dir: ""
|
dir: ""
|
||||||
# The host of the cache server.
|
# Outbound IP or hostname that job containers use to reach this runner's cache server.
|
||||||
# It's not for the address to listen, but the address to connect from job containers.
|
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
|
||||||
# So 0.0.0.0 is a bad choice, leave it empty to detect automatically.
|
# Ignored when external_server is set.
|
||||||
host: ""
|
host: ""
|
||||||
# The port of the cache server.
|
# Port for the built-in cache server. 0 picks a random free port.
|
||||||
# 0 means to use a random available port.
|
# Ignored when external_server is set.
|
||||||
port: 0
|
port: 0
|
||||||
# The external cache server URL. Valid only when enable is true.
|
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
|
||||||
# If it's specified, runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
|
# Set on every runner that should share a cache pool. Must end with "/".
|
||||||
# The URL should generally end with "/".
|
# Example: "http://cache-host:8088/"
|
||||||
# Requires external_secret below to be set to the same value on both this runner and the cache-server.
|
# Requires external_secret (below) to match the value on the cache-server.
|
||||||
external_server: ""
|
external_server: ""
|
||||||
# Shared secret between this runner and the external `gitea-runner cache-server`. Required when external_server
|
# Shared secret between this runner and the external cache-server.
|
||||||
# (or `gitea-runner cache-server`) is in use: the runner pre-registers each job's ACTIONS_RUNTIME_TOKEN with the
|
# Required when external_server is set. Must be identical on every runner and the cache-server.
|
||||||
# cache-server, and the cache-server enforces bearer auth + per-repo cache isolation.
|
# Generate with: openssl rand -hex 32
|
||||||
external_secret: ""
|
external_secret: ""
|
||||||
# When true, reuse a cached action instead of fetching from the remote on every job. Note: a moved tag
|
# When true, reuse a cached action instead of fetching from the remote on every job.
|
||||||
# (e.g. a re-tagged "v6") or an updated branch stays at the cached commit until its cache entry is removed.
|
# 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
|
offline_mode: false
|
||||||
|
|
||||||
container:
|
container:
|
||||||
@@ -116,6 +135,13 @@ container:
|
|||||||
# If it's empty, runner will create a network automatically.
|
# If it's empty, runner will create a network automatically.
|
||||||
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
||||||
network: ""
|
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).
|
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||||
privileged: false
|
privileged: false
|
||||||
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).
|
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import (
|
|||||||
"go.yaml.in/yaml/v4"
|
"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.
|
// Log represents the configuration for logging.
|
||||||
type Log struct {
|
type Log struct {
|
||||||
Level string `yaml:"level"` // Level indicates the logging level.
|
Level string `yaml:"level"` // Level indicates the logging level.
|
||||||
@@ -42,7 +48,10 @@ type Runner struct {
|
|||||||
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.
|
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
|
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
|
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.
|
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.
|
// Cache represents the configuration for caching.
|
||||||
@@ -59,6 +68,7 @@ type Cache struct {
|
|||||||
// Container represents the configuration for the container.
|
// Container represents the configuration for the container.
|
||||||
type Container struct {
|
type Container struct {
|
||||||
Network string `yaml:"network"` // Network specifies the network for the container.
|
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
|
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.
|
Privileged bool `yaml:"privileged"` // Privileged indicates whether the container runs in privileged mode.
|
||||||
Options string `yaml:"options"` // Options specifies additional options for the container.
|
Options string `yaml:"options"` // Options specifies additional options for the container.
|
||||||
@@ -72,6 +82,11 @@ type Container struct {
|
|||||||
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.
|
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.
|
// Host represents the configuration for the host.
|
||||||
type Host struct {
|
type Host struct {
|
||||||
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
|
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
|
||||||
@@ -137,6 +152,10 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if cfg.Runner.Timeout <= 0 {
|
if cfg.Runner.Timeout <= 0 {
|
||||||
cfg.Runner.Timeout = 3 * time.Hour
|
cfg.Runner.Timeout = 3 * time.Hour
|
||||||
}
|
}
|
||||||
|
if cfg.Runner.ActionShallowClone == nil {
|
||||||
|
b := true
|
||||||
|
cfg.Runner.ActionShallowClone = &b
|
||||||
|
}
|
||||||
if cfg.Cache.Enabled == nil {
|
if cfg.Cache.Enabled == nil {
|
||||||
b := true
|
b := true
|
||||||
cfg.Cache.Enabled = &b
|
cfg.Cache.Enabled = &b
|
||||||
@@ -187,6 +206,9 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if cfg.Runner.ReportCloseTimeout <= 0 {
|
if cfg.Runner.ReportCloseTimeout <= 0 {
|
||||||
cfg.Runner.ReportCloseTimeout = 10 * time.Second
|
cfg.Runner.ReportCloseTimeout = 10 * time.Second
|
||||||
}
|
}
|
||||||
|
if cfg.Runner.PostTaskScript != "" && cfg.Runner.PostTaskScriptTimeout <= 0 {
|
||||||
|
cfg.Runner.PostTaskScriptTimeout = DefaultPostTaskScriptTimeout
|
||||||
|
}
|
||||||
if cfg.Metrics.Addr == "" {
|
if cfg.Metrics.Addr == "" {
|
||||||
cfg.Metrics.Addr = "127.0.0.1:9101"
|
cfg.Metrics.Addr = "127.0.0.1:9101"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,34 @@ runner:
|
|||||||
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
|
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
|
||||||
// invalid YAML to the canonical "parse config file" message rather than the
|
// invalid YAML to the canonical "parse config file" message rather than the
|
||||||
// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first.
|
// "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) {
|
func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yaml")
|
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.Contains(t, err.Error(), "parse config file")
|
||||||
assert.NotContains(t, err.Error(), "defaults metadata")
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
29
internal/pkg/process/killer_plan9.go
Normal file
29
internal/pkg/process/killer_plan9.go
Normal 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 }
|
||||||
56
internal/pkg/process/killer_unix.go
Normal file
56
internal/pkg/process/killer_unix.go
Normal 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 }
|
||||||
101
internal/pkg/process/killer_unix_test.go
Normal file
101
internal/pkg/process/killer_unix_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !windows && !plan9
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processAlive reports whether pid refers to a still-running process. Signal 0
|
||||||
|
// performs error checking without delivering a signal: a nil error (or EPERM)
|
||||||
|
// means the process exists, ESRCH means it is gone.
|
||||||
|
//
|
||||||
|
// On Linux, zombie processes (state Z in /proc/<pid>/stat) appear alive to
|
||||||
|
// kill(0) but have already terminated — their corpse lingers until the parent
|
||||||
|
// calls wait(). In a Docker container the child may be reparented to a PID 1
|
||||||
|
// that does not reap promptly, so we treat zombies as not alive.
|
||||||
|
func processAlive(pid int) bool {
|
||||||
|
err := syscall.Kill(pid, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// On Linux /proc is available; check whether the process is a zombie.
|
||||||
|
if b, readErr := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); readErr == nil {
|
||||||
|
// Format: "pid (comm) state ..." — state follows the closing ')' of the
|
||||||
|
// command name (which may itself contain spaces and parens).
|
||||||
|
rest := string(b)
|
||||||
|
if idx := strings.LastIndex(rest, ") "); idx >= 0 {
|
||||||
|
fields := strings.Fields(rest[idx+2:])
|
||||||
|
if len(fields) > 0 && fields[0] == "Z" {
|
||||||
|
return false // zombie: terminated but not yet reaped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// Parent shell backgrounds a long-lived child (writing its PID to a file)
|
||||||
|
// and then sleeps. With job control off (non-interactive sh) the backgrounded
|
||||||
|
// 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 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() {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
_ = cmd.Wait()
|
||||||
|
})
|
||||||
|
|
||||||
|
killer, err := NewKiller(cmd.Process)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer killer.Close()
|
||||||
|
|
||||||
|
// Wait for the backgrounded child PID to be reported.
|
||||||
|
var childPID int
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
b, e := os.ReadFile(pidFile)
|
||||||
|
if e != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(b))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
childPID, _ = strconv.Atoi(s)
|
||||||
|
return childPID > 0 && processAlive(childPID)
|
||||||
|
}, 20*time.Second, 100*time.Millisecond, "child process should start")
|
||||||
|
|
||||||
|
// Killing the group must terminate both the parent and the backgrounded child.
|
||||||
|
require.NoError(t, killer.Kill())
|
||||||
|
// Reap the parent so it does not linger as a zombie (which would still report
|
||||||
|
// as alive); SIGKILL makes Wait return promptly.
|
||||||
|
_ = cmd.Wait()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return !processAlive(childPID)
|
||||||
|
}, 20*time.Second, 100*time.Millisecond, "backgrounded child should be terminated")
|
||||||
|
}
|
||||||
72
internal/pkg/process/killer_windows.go
Normal file
72
internal/pkg/process/killer_windows.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package container
|
package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -23,7 +23,7 @@ func processAlive(pid int) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(h)
|
defer func() { _ = windows.CloseHandle(h) }()
|
||||||
var code uint32
|
var code uint32
|
||||||
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -32,11 +32,11 @@ func processAlive(pid int) bool {
|
|||||||
return code == stillActive
|
return code == stillActive
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProcessKillerKillsTree verifies that a process assigned to the Job Object
|
// TestKillerKillsTree verifies that a process assigned to the Job Object is
|
||||||
// is terminated together with a child it spawns afterwards. This mirrors a step
|
// terminated together with a child it spawns afterwards. This mirrors a step or
|
||||||
// that launches a child which spawns further processes, where cancelling the
|
// post-task script that launches a child which spawns further processes, where
|
||||||
// job must take down the whole tree, not just the direct child.
|
// cancelling must take down the whole tree, not just the direct child.
|
||||||
func TestProcessKillerKillsTree(t *testing.T) {
|
func TestKillerKillsTree(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
pidFile := filepath.Join(dir, "child.pid")
|
pidFile := filepath.Join(dir, "child.pid")
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ func TestProcessKillerKillsTree(t *testing.T) {
|
|||||||
require.NoError(t, cmd.Start())
|
require.NoError(t, cmd.Start())
|
||||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||||
|
|
||||||
killer, err := newProcessKiller(cmd.Process)
|
killer, err := NewKiller(cmd.Process)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer killer.Close()
|
defer killer.Close()
|
||||||
|
|
||||||
17
internal/pkg/process/sysprocattr_plan9.go
Normal file
17
internal/pkg/process/sysprocattr_plan9.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
24
internal/pkg/process/sysprocattr_unix.go
Normal file
24
internal/pkg/process/sysprocattr_unix.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
14
internal/pkg/process/sysprocattr_windows.go
Normal file
14
internal/pkg/process/sysprocattr_windows.go
Normal 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}
|
||||||
|
}
|
||||||
66
internal/pkg/process/treekill.go
Normal file
66
internal/pkg/process/treekill.go
Normal 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
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ type Reporter struct {
|
|||||||
stateMu sync.RWMutex
|
stateMu sync.RWMutex
|
||||||
outputs sync.Map
|
outputs sync.Map
|
||||||
daemon chan struct{}
|
daemon chan struct{}
|
||||||
|
heartbeatStop chan struct{}
|
||||||
|
heartbeatStopOnce sync.Once
|
||||||
|
|
||||||
// Unix-nanos of the last successful UpdateTask. Atomic so the heartbeat
|
// Unix-nanos of the last successful UpdateTask. Atomic so the heartbeat
|
||||||
// guard in ReportState reads it without contending stateMu.
|
// guard in ReportState reads it without contending stateMu.
|
||||||
@@ -100,6 +102,7 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
|
|||||||
Id: task.Id,
|
Id: task.Id,
|
||||||
},
|
},
|
||||||
daemon: make(chan struct{}),
|
daemon: make(chan struct{}),
|
||||||
|
heartbeatStop: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" {
|
if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" {
|
||||||
@@ -273,6 +276,15 @@ func (r *Reporter) RunDaemon() {
|
|||||||
go r.runDaemonLoop()
|
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) {
|
func (r *Reporter) stopLatencyTimer(active *bool, timer *time.Timer) {
|
||||||
if *active {
|
if *active {
|
||||||
if !timer.Stop() {
|
if !timer.Stop() {
|
||||||
@@ -339,6 +351,12 @@ func (r *Reporter) runDaemonLoop() {
|
|||||||
// delivers the final flush on a detached context (flushFinal).
|
// delivers the final flush on a detached context (flushFinal).
|
||||||
close(r.daemon)
|
close(r.daemon)
|
||||||
return
|
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()
|
r.stateMu.RLock()
|
||||||
|
|||||||
@@ -921,3 +921,65 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
|
|||||||
assert.True(t, foundCancelled, "final log must contain a 'Cancelled' row")
|
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")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user